@screenbook/cli 1.1.2 → 1.2.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 +1150 -76
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
package/dist/index.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { defineConfig } from "@screenbook/core";
|
|
|
9
9
|
import pc from "picocolors";
|
|
10
10
|
import { execSync, spawn } from "node:child_process";
|
|
11
11
|
import prompts from "prompts";
|
|
12
|
+
import { parse } from "@babel/parser";
|
|
12
13
|
import { minimatch } from "minimatch";
|
|
13
14
|
|
|
14
15
|
//#region src/utils/config.ts
|
|
@@ -166,14 +167,34 @@ function getCycleSummary(result) {
|
|
|
166
167
|
*/
|
|
167
168
|
const ERRORS = {
|
|
168
169
|
ROUTES_PATTERN_MISSING: {
|
|
169
|
-
title: "
|
|
170
|
-
suggestion: "Add routesPattern
|
|
170
|
+
title: "Routes configuration not found",
|
|
171
|
+
suggestion: "Add routesPattern (for file-based routing) or routesFile (for config-based routing) to your screenbook.config.ts.",
|
|
171
172
|
example: `import { defineConfig } from "@screenbook/core"
|
|
172
173
|
|
|
174
|
+
// Option 1: File-based routing (Next.js, Nuxt, etc.)
|
|
173
175
|
export default defineConfig({
|
|
174
|
-
routesPattern: "src/pages/**/page.tsx",
|
|
176
|
+
routesPattern: "src/pages/**/page.tsx",
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Option 2: Config-based routing (Vue Router, React Router, etc.)
|
|
180
|
+
export default defineConfig({
|
|
181
|
+
routesFile: "src/router/routes.ts",
|
|
175
182
|
})`
|
|
176
183
|
},
|
|
184
|
+
ROUTES_FILE_NOT_FOUND: (filePath) => ({
|
|
185
|
+
title: `Routes file not found: ${filePath}`,
|
|
186
|
+
suggestion: "Check the routesFile path in your screenbook.config.ts. The file should export a routes array.",
|
|
187
|
+
example: `import { defineConfig } from "@screenbook/core"
|
|
188
|
+
|
|
189
|
+
export default defineConfig({
|
|
190
|
+
routesFile: "src/router/routes.ts", // Make sure this file exists
|
|
191
|
+
})`
|
|
192
|
+
}),
|
|
193
|
+
ROUTES_FILE_PARSE_ERROR: (filePath, error) => ({
|
|
194
|
+
title: `Failed to parse routes file: ${filePath}`,
|
|
195
|
+
message: error,
|
|
196
|
+
suggestion: "Ensure the file exports a valid routes array. Check for syntax errors or unsupported patterns."
|
|
197
|
+
}),
|
|
177
198
|
CONFIG_NOT_FOUND: {
|
|
178
199
|
title: "Configuration file not found",
|
|
179
200
|
suggestion: "Run 'screenbook init' to create a screenbook.config.ts file, or create one manually.",
|
|
@@ -733,13 +754,19 @@ async function checkDependencies(cwd) {
|
|
|
733
754
|
...pkg.dependencies,
|
|
734
755
|
...pkg.devDependencies
|
|
735
756
|
};
|
|
757
|
+
const unifiedVersion = allDeps.screenbook;
|
|
736
758
|
const coreVersion = allDeps["@screenbook/core"];
|
|
737
759
|
const cliVersion = allDeps["@screenbook/cli"];
|
|
760
|
+
if (unifiedVersion) return {
|
|
761
|
+
name: "Dependencies",
|
|
762
|
+
status: "pass",
|
|
763
|
+
message: `screenbook@${unifiedVersion}`
|
|
764
|
+
};
|
|
738
765
|
if (!coreVersion && !cliVersion) return {
|
|
739
766
|
name: "Dependencies",
|
|
740
767
|
status: "fail",
|
|
741
768
|
message: "Screenbook packages not installed",
|
|
742
|
-
suggestion: "Run 'pnpm add -D @screenbook/core @screenbook/cli' to install"
|
|
769
|
+
suggestion: "Run 'pnpm add -D screenbook' or 'pnpm add -D @screenbook/core @screenbook/cli' to install"
|
|
743
770
|
};
|
|
744
771
|
if (!coreVersion) return {
|
|
745
772
|
name: "Dependencies",
|
|
@@ -864,8 +891,14 @@ async function checkVersionCompatibility(cwd) {
|
|
|
864
891
|
...pkg.dependencies,
|
|
865
892
|
...pkg.devDependencies
|
|
866
893
|
};
|
|
894
|
+
const unifiedVersion = allDeps.screenbook;
|
|
867
895
|
const coreVersion = allDeps["@screenbook/core"];
|
|
868
896
|
const cliVersion = allDeps["@screenbook/cli"];
|
|
897
|
+
if (unifiedVersion) return {
|
|
898
|
+
name: "Version compatibility",
|
|
899
|
+
status: "pass",
|
|
900
|
+
message: "Using unified screenbook package"
|
|
901
|
+
};
|
|
869
902
|
if (!coreVersion || !cliVersion) return {
|
|
870
903
|
name: "Version compatibility",
|
|
871
904
|
status: "warn",
|
|
@@ -931,6 +964,721 @@ function displayResults(results, verbose) {
|
|
|
931
964
|
}
|
|
932
965
|
}
|
|
933
966
|
|
|
967
|
+
//#endregion
|
|
968
|
+
//#region src/utils/routeParserUtils.ts
|
|
969
|
+
/**
|
|
970
|
+
* Resolve relative import path to absolute path
|
|
971
|
+
*/
|
|
972
|
+
function resolveImportPath(importPath, baseDir) {
|
|
973
|
+
if (importPath.startsWith(".")) return resolve(baseDir, importPath);
|
|
974
|
+
return importPath;
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Flatten nested routes into a flat list with computed properties
|
|
978
|
+
*/
|
|
979
|
+
function flattenRoutes(routes, parentPath = "", depth = 0) {
|
|
980
|
+
const result = [];
|
|
981
|
+
for (const route of routes) {
|
|
982
|
+
if (route.redirect && !route.component) continue;
|
|
983
|
+
let fullPath;
|
|
984
|
+
if (route.path.startsWith("/")) fullPath = route.path;
|
|
985
|
+
else if (parentPath === "/") fullPath = `/${route.path}`;
|
|
986
|
+
else fullPath = parentPath ? `${parentPath}/${route.path}` : `/${route.path}`;
|
|
987
|
+
fullPath = fullPath.replace(/\/+/g, "/");
|
|
988
|
+
if (fullPath !== "/" && fullPath.endsWith("/")) fullPath = fullPath.slice(0, -1);
|
|
989
|
+
if (fullPath === "") fullPath = parentPath || "/";
|
|
990
|
+
if (route.component || !route.children) result.push({
|
|
991
|
+
fullPath,
|
|
992
|
+
name: route.name,
|
|
993
|
+
componentPath: route.component,
|
|
994
|
+
screenId: pathToScreenId(fullPath),
|
|
995
|
+
screenTitle: pathToScreenTitle(fullPath),
|
|
996
|
+
depth
|
|
997
|
+
});
|
|
998
|
+
if (route.children) result.push(...flattenRoutes(route.children, fullPath, depth + 1));
|
|
999
|
+
}
|
|
1000
|
+
return result;
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Convert route path to screen ID
|
|
1004
|
+
* /user/:id/profile -> user.id.profile
|
|
1005
|
+
*/
|
|
1006
|
+
function pathToScreenId(path) {
|
|
1007
|
+
if (path === "/" || path === "") return "home";
|
|
1008
|
+
return path.replace(/^\//, "").replace(/\/$/, "").split("/").map((segment) => {
|
|
1009
|
+
if (segment.startsWith(":")) return segment.slice(1);
|
|
1010
|
+
if (segment.startsWith("*")) return segment.slice(1) || "catchall";
|
|
1011
|
+
return segment;
|
|
1012
|
+
}).join(".");
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Convert route path to screen title
|
|
1016
|
+
* /user/:id/profile -> Profile
|
|
1017
|
+
*/
|
|
1018
|
+
function pathToScreenTitle(path) {
|
|
1019
|
+
if (path === "/" || path === "") return "Home";
|
|
1020
|
+
const segments = path.replace(/^\//, "").replace(/\/$/, "").split("/").filter((s) => !s.startsWith(":") && !s.startsWith("*"));
|
|
1021
|
+
return (segments[segments.length - 1] || "Home").split(/[-_]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
//#endregion
|
|
1025
|
+
//#region src/utils/tanstackRouterParser.ts
|
|
1026
|
+
/**
|
|
1027
|
+
* Parse TanStack Router configuration file and extract routes
|
|
1028
|
+
*/
|
|
1029
|
+
function parseTanStackRouterConfig(filePath, preloadedContent) {
|
|
1030
|
+
const absolutePath = resolve(filePath);
|
|
1031
|
+
const routesFileDir = dirname(absolutePath);
|
|
1032
|
+
const warnings = [];
|
|
1033
|
+
let content;
|
|
1034
|
+
if (preloadedContent !== void 0) content = preloadedContent;
|
|
1035
|
+
else try {
|
|
1036
|
+
content = readFileSync(absolutePath, "utf-8");
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1039
|
+
throw new Error(`Failed to read routes file "${absolutePath}": ${message}`);
|
|
1040
|
+
}
|
|
1041
|
+
let ast;
|
|
1042
|
+
try {
|
|
1043
|
+
ast = parse(content, {
|
|
1044
|
+
sourceType: "module",
|
|
1045
|
+
plugins: ["typescript", "jsx"]
|
|
1046
|
+
});
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
if (error instanceof SyntaxError) throw new Error(`Syntax error in routes file "${absolutePath}": ${error.message}`);
|
|
1049
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1050
|
+
throw new Error(`Failed to parse routes file "${absolutePath}": ${message}`);
|
|
1051
|
+
}
|
|
1052
|
+
const routeMap = /* @__PURE__ */ new Map();
|
|
1053
|
+
for (const node of ast.program.body) collectRouteDefinitions(node, routeMap, routesFileDir, warnings);
|
|
1054
|
+
for (const node of ast.program.body) processAddChildrenCalls(node, routeMap, warnings);
|
|
1055
|
+
const routes = buildRouteTree(routeMap, warnings);
|
|
1056
|
+
if (routes.length === 0) warnings.push("No routes found. Supported patterns: 'createRootRoute()', 'createRoute()', and '.addChildren([...])'");
|
|
1057
|
+
return {
|
|
1058
|
+
routes,
|
|
1059
|
+
warnings
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Collect route definitions from AST nodes
|
|
1064
|
+
*/
|
|
1065
|
+
function collectRouteDefinitions(node, routeMap, baseDir, warnings) {
|
|
1066
|
+
if (node.type === "VariableDeclaration") for (const decl of node.declarations) {
|
|
1067
|
+
if (decl.id.type !== "Identifier") continue;
|
|
1068
|
+
const variableName = decl.id.name;
|
|
1069
|
+
if (decl.init?.type === "CallExpression") {
|
|
1070
|
+
const routeDef = extractRouteFromCallExpression(decl.init, variableName, baseDir, warnings);
|
|
1071
|
+
if (routeDef) routeMap.set(variableName, routeDef);
|
|
1072
|
+
}
|
|
1073
|
+
if (decl.init?.type === "CallExpression" && decl.init.callee?.type === "MemberExpression" && decl.init.callee.property?.type === "Identifier" && decl.init.callee.property.name === "lazy") {
|
|
1074
|
+
const createRouteCall = decl.init.callee.object;
|
|
1075
|
+
if (createRouteCall?.type === "CallExpression") {
|
|
1076
|
+
const routeDef = extractRouteFromCallExpression(createRouteCall, variableName, baseDir, warnings);
|
|
1077
|
+
if (routeDef) {
|
|
1078
|
+
const lazyArg = decl.init.arguments[0];
|
|
1079
|
+
if (lazyArg) {
|
|
1080
|
+
const lazyPath = extractLazyImportPath$1(lazyArg, baseDir, warnings);
|
|
1081
|
+
if (lazyPath) routeDef.component = lazyPath;
|
|
1082
|
+
}
|
|
1083
|
+
routeMap.set(variableName, routeDef);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
if (node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration") for (const decl of node.declaration.declarations) {
|
|
1089
|
+
if (decl.id.type !== "Identifier") continue;
|
|
1090
|
+
const variableName = decl.id.name;
|
|
1091
|
+
if (decl.init?.type === "CallExpression") {
|
|
1092
|
+
const routeDef = extractRouteFromCallExpression(decl.init, variableName, baseDir, warnings);
|
|
1093
|
+
if (routeDef) routeMap.set(variableName, routeDef);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Extract route definition from a CallExpression (createRoute or createRootRoute)
|
|
1099
|
+
*/
|
|
1100
|
+
function extractRouteFromCallExpression(callNode, variableName, baseDir, warnings) {
|
|
1101
|
+
const callee = callNode.callee;
|
|
1102
|
+
let isRoot = false;
|
|
1103
|
+
let optionsArg = callNode.arguments[0];
|
|
1104
|
+
if (callee.type === "Identifier") {
|
|
1105
|
+
if (callee.name === "createRootRoute") isRoot = true;
|
|
1106
|
+
else if (callee.name === "createRootRouteWithContext") isRoot = true;
|
|
1107
|
+
else if (callee.name !== "createRoute") return null;
|
|
1108
|
+
} else if (callee.type === "CallExpression") {
|
|
1109
|
+
const innerCallee = callee.callee;
|
|
1110
|
+
if (innerCallee?.type === "Identifier" && innerCallee.name === "createRootRouteWithContext") {
|
|
1111
|
+
isRoot = true;
|
|
1112
|
+
optionsArg = callNode.arguments[0];
|
|
1113
|
+
} else return null;
|
|
1114
|
+
} else return null;
|
|
1115
|
+
const routeDef = {
|
|
1116
|
+
variableName,
|
|
1117
|
+
isRoot
|
|
1118
|
+
};
|
|
1119
|
+
if (optionsArg?.type === "ObjectExpression") for (const prop of optionsArg.properties) {
|
|
1120
|
+
if (prop.type !== "ObjectProperty") continue;
|
|
1121
|
+
if (prop.key.type !== "Identifier") continue;
|
|
1122
|
+
switch (prop.key.name) {
|
|
1123
|
+
case "path":
|
|
1124
|
+
if (prop.value.type === "StringLiteral") routeDef.path = normalizeTanStackPath(prop.value.value);
|
|
1125
|
+
else {
|
|
1126
|
+
const loc = prop.loc ? ` at line ${prop.loc.start.line}` : "";
|
|
1127
|
+
warnings.push(`Dynamic path value (${prop.value.type})${loc}. Only string literal paths can be statically analyzed.`);
|
|
1128
|
+
}
|
|
1129
|
+
break;
|
|
1130
|
+
case "component":
|
|
1131
|
+
routeDef.component = extractComponentValue(prop.value, baseDir, warnings);
|
|
1132
|
+
break;
|
|
1133
|
+
case "getParentRoute":
|
|
1134
|
+
if (prop.value.type === "ArrowFunctionExpression") {
|
|
1135
|
+
const body = prop.value.body;
|
|
1136
|
+
if (body.type === "Identifier") routeDef.parentVariableName = body.name;
|
|
1137
|
+
else {
|
|
1138
|
+
const loc = prop.loc ? ` at line ${prop.loc.start.line}` : "";
|
|
1139
|
+
warnings.push(`Dynamic getParentRoute${loc}. Only static route references can be analyzed.`);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
break;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return routeDef;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Extract component value from different patterns
|
|
1149
|
+
* Returns undefined with a warning for unrecognized patterns
|
|
1150
|
+
*/
|
|
1151
|
+
function extractComponentValue(node, baseDir, warnings) {
|
|
1152
|
+
if (node.type === "Identifier") return node.name;
|
|
1153
|
+
if (node.type === "CallExpression") {
|
|
1154
|
+
const callee = node.callee;
|
|
1155
|
+
if (callee.type === "Identifier" && callee.name === "lazyRouteComponent") {
|
|
1156
|
+
const importArg = node.arguments[0];
|
|
1157
|
+
if (importArg) return extractLazyImportPath$1(importArg, baseDir, warnings);
|
|
1158
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1159
|
+
warnings.push(`lazyRouteComponent called without arguments${loc}. Expected arrow function with import().`);
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
if (node.type === "ArrowFunctionExpression") {
|
|
1165
|
+
if (node.body.type === "JSXElement") {
|
|
1166
|
+
const openingElement = node.body.openingElement;
|
|
1167
|
+
if (openingElement?.name?.type === "JSXIdentifier") return openingElement.name.name;
|
|
1168
|
+
if (openingElement?.name?.type === "JSXMemberExpression") {
|
|
1169
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1170
|
+
warnings.push(`Namespaced JSX component${loc}. Component extraction not fully supported for member expressions.`);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
if (node.body.type === "BlockStatement") {
|
|
1175
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1176
|
+
warnings.push(`Arrow function with block body${loc}. Only concise arrow functions returning JSX directly can be analyzed.`);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
if (node.body.type === "JSXFragment") {
|
|
1180
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1181
|
+
warnings.push(`JSX Fragment detected${loc}. Cannot extract component name from fragments.`);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Extract import path from lazy function patterns
|
|
1188
|
+
*/
|
|
1189
|
+
function extractLazyImportPath$1(node, baseDir, warnings) {
|
|
1190
|
+
if (node.type === "ArrowFunctionExpression") {
|
|
1191
|
+
const body = node.body;
|
|
1192
|
+
if (body.type === "CallExpression" && body.callee.type === "Import") {
|
|
1193
|
+
if (body.arguments[0]?.type === "StringLiteral") return resolveImportPath(body.arguments[0].value, baseDir);
|
|
1194
|
+
}
|
|
1195
|
+
if (body.type === "CallExpression" && body.callee.type === "MemberExpression" && body.callee.object?.type === "CallExpression" && body.callee.object.callee?.type === "Import") {
|
|
1196
|
+
const importCall = body.callee.object;
|
|
1197
|
+
if (importCall.arguments[0]?.type === "StringLiteral") return resolveImportPath(importCall.arguments[0].value, baseDir);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1201
|
+
warnings.push(`Unrecognized lazy pattern (${node.type})${loc}. Expected arrow function with import().`);
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Process addChildren calls to establish parent-child relationships
|
|
1205
|
+
*/
|
|
1206
|
+
function processAddChildrenCalls(node, routeMap, warnings) {
|
|
1207
|
+
if (node.type === "VariableDeclaration") for (const decl of node.declarations) processAddChildrenExpression(decl.init, routeMap, warnings);
|
|
1208
|
+
if (node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration") for (const decl of node.declaration.declarations) processAddChildrenExpression(decl.init, routeMap, warnings);
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Recursively process addChildren expressions
|
|
1212
|
+
*/
|
|
1213
|
+
function processAddChildrenExpression(expr, routeMap, warnings) {
|
|
1214
|
+
if (!expr) return void 0;
|
|
1215
|
+
if (expr.type === "CallExpression" && expr.callee?.type === "MemberExpression" && expr.callee.property?.type === "Identifier" && expr.callee.property.name === "addChildren") {
|
|
1216
|
+
let parentVarName;
|
|
1217
|
+
if (expr.callee.object?.type === "Identifier") parentVarName = expr.callee.object.name;
|
|
1218
|
+
else if (expr.callee.object?.type === "CallExpression") parentVarName = processAddChildrenExpression(expr.callee.object, routeMap, warnings);
|
|
1219
|
+
if (!parentVarName) return void 0;
|
|
1220
|
+
const parentDef = routeMap.get(parentVarName);
|
|
1221
|
+
if (!parentDef) {
|
|
1222
|
+
const loc = expr.loc ? ` at line ${expr.loc.start.line}` : "";
|
|
1223
|
+
warnings.push(`Parent route "${parentVarName}" not found${loc}. Ensure it's defined with createRoute/createRootRoute.`);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
const childrenArg = expr.arguments[0];
|
|
1227
|
+
if (childrenArg?.type === "ArrayExpression") {
|
|
1228
|
+
const childNames = [];
|
|
1229
|
+
for (const element of childrenArg.elements) {
|
|
1230
|
+
if (!element) continue;
|
|
1231
|
+
if (element.type === "Identifier") childNames.push(element.name);
|
|
1232
|
+
else if (element.type === "CallExpression") {
|
|
1233
|
+
const nestedParent = processAddChildrenExpression(element, routeMap, warnings);
|
|
1234
|
+
if (nestedParent) childNames.push(nestedParent);
|
|
1235
|
+
} else if (element.type === "SpreadElement") {
|
|
1236
|
+
const loc = element.loc ? ` at line ${element.loc.start.line}` : "";
|
|
1237
|
+
warnings.push(`Spread operator detected${loc}. Routes from spread cannot be statically analyzed.`);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
parentDef.children = childNames;
|
|
1241
|
+
}
|
|
1242
|
+
return parentVarName;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* Build the route tree from collected definitions
|
|
1247
|
+
*/
|
|
1248
|
+
function buildRouteTree(routeMap, warnings) {
|
|
1249
|
+
const rootDefs = Array.from(routeMap.values()).filter((def) => def.isRoot);
|
|
1250
|
+
if (rootDefs.length === 0) return buildTreeFromParentRelations(routeMap, warnings);
|
|
1251
|
+
const routes = [];
|
|
1252
|
+
for (const rootDef of rootDefs) {
|
|
1253
|
+
const rootRoute = buildRouteFromDefinition(rootDef, routeMap, warnings, /* @__PURE__ */ new Set());
|
|
1254
|
+
if (rootRoute) {
|
|
1255
|
+
if (rootRoute.children && rootRoute.children.length > 0) routes.push(...rootRoute.children);
|
|
1256
|
+
else if (rootRoute.path) routes.push(rootRoute);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return routes;
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Build tree when there's no explicit root route
|
|
1263
|
+
* Falls back to finding routes that have no parent relationship defined
|
|
1264
|
+
*/
|
|
1265
|
+
function buildTreeFromParentRelations(routeMap, warnings) {
|
|
1266
|
+
const topLevelDefs = Array.from(routeMap.values()).filter((def) => !def.parentVariableName && !def.isRoot);
|
|
1267
|
+
const routes = [];
|
|
1268
|
+
for (const def of topLevelDefs) {
|
|
1269
|
+
const route = buildRouteFromDefinition(def, routeMap, warnings, /* @__PURE__ */ new Set());
|
|
1270
|
+
if (route) routes.push(route);
|
|
1271
|
+
}
|
|
1272
|
+
return routes;
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Build a ParsedRoute from a RouteDefinition
|
|
1276
|
+
* @param visited - Set of visited variable names for circular reference detection
|
|
1277
|
+
*/
|
|
1278
|
+
function buildRouteFromDefinition(def, routeMap, warnings, visited) {
|
|
1279
|
+
if (visited.has(def.variableName)) {
|
|
1280
|
+
warnings.push(`Circular reference detected: route "${def.variableName}" references itself in the route tree.`);
|
|
1281
|
+
return null;
|
|
1282
|
+
}
|
|
1283
|
+
visited.add(def.variableName);
|
|
1284
|
+
const route = {
|
|
1285
|
+
path: def.path ?? "",
|
|
1286
|
+
component: def.component
|
|
1287
|
+
};
|
|
1288
|
+
if (def.children && def.children.length > 0) {
|
|
1289
|
+
const children = [];
|
|
1290
|
+
for (const childName of def.children) {
|
|
1291
|
+
const childDef = routeMap.get(childName);
|
|
1292
|
+
if (childDef) {
|
|
1293
|
+
const childRoute = buildRouteFromDefinition(childDef, routeMap, warnings, visited);
|
|
1294
|
+
if (childRoute) children.push(childRoute);
|
|
1295
|
+
} else warnings.push(`Child route "${childName}" not found. Ensure it's defined with createRoute.`);
|
|
1296
|
+
}
|
|
1297
|
+
if (children.length > 0) route.children = children;
|
|
1298
|
+
}
|
|
1299
|
+
return route;
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Normalize TanStack Router path syntax to standard format
|
|
1303
|
+
* /$ (trailing catch-all) -> /*
|
|
1304
|
+
* $ (standalone splat) -> *
|
|
1305
|
+
* $param (dynamic segment) -> :param
|
|
1306
|
+
*/
|
|
1307
|
+
function normalizeTanStackPath(path) {
|
|
1308
|
+
return path.replace(/\/\$$/, "/*").replace(/^\$$/, "*").replace(/\$([a-zA-Z_][a-zA-Z0-9_]*)/g, ":$1");
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Detect if content is TanStack Router based on patterns
|
|
1312
|
+
*/
|
|
1313
|
+
function isTanStackRouterContent(content) {
|
|
1314
|
+
if (content.includes("@tanstack/react-router")) return true;
|
|
1315
|
+
if (content.includes("createRootRoute")) return true;
|
|
1316
|
+
if (content.includes("createRoute") && content.includes("getParentRoute")) return true;
|
|
1317
|
+
if (content.includes("lazyRouteComponent")) return true;
|
|
1318
|
+
if (/\.addChildren\s*\(/.test(content)) return true;
|
|
1319
|
+
return false;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
//#endregion
|
|
1323
|
+
//#region src/utils/reactRouterParser.ts
|
|
1324
|
+
/**
|
|
1325
|
+
* Router factory function names to detect
|
|
1326
|
+
*/
|
|
1327
|
+
const ROUTER_FACTORY_NAMES = [
|
|
1328
|
+
"createBrowserRouter",
|
|
1329
|
+
"createHashRouter",
|
|
1330
|
+
"createMemoryRouter"
|
|
1331
|
+
];
|
|
1332
|
+
/**
|
|
1333
|
+
* Parse React Router configuration file and extract routes
|
|
1334
|
+
*/
|
|
1335
|
+
function parseReactRouterConfig(filePath, preloadedContent) {
|
|
1336
|
+
const absolutePath = resolve(filePath);
|
|
1337
|
+
const routesFileDir = dirname(absolutePath);
|
|
1338
|
+
const warnings = [];
|
|
1339
|
+
let content;
|
|
1340
|
+
if (preloadedContent !== void 0) content = preloadedContent;
|
|
1341
|
+
else try {
|
|
1342
|
+
content = readFileSync(absolutePath, "utf-8");
|
|
1343
|
+
} catch (error) {
|
|
1344
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1345
|
+
throw new Error(`Failed to read routes file "${absolutePath}": ${message}`);
|
|
1346
|
+
}
|
|
1347
|
+
let ast;
|
|
1348
|
+
try {
|
|
1349
|
+
ast = parse(content, {
|
|
1350
|
+
sourceType: "module",
|
|
1351
|
+
plugins: ["typescript", "jsx"]
|
|
1352
|
+
});
|
|
1353
|
+
} catch (error) {
|
|
1354
|
+
if (error instanceof SyntaxError) throw new Error(`Syntax error in routes file "${absolutePath}": ${error.message}`);
|
|
1355
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1356
|
+
throw new Error(`Failed to parse routes file "${absolutePath}": ${message}`);
|
|
1357
|
+
}
|
|
1358
|
+
const routes = [];
|
|
1359
|
+
for (const node of ast.program.body) {
|
|
1360
|
+
if (node.type === "VariableDeclaration") {
|
|
1361
|
+
for (const decl of node.declarations) if (decl.init?.type === "CallExpression") {
|
|
1362
|
+
const callee = decl.init.callee;
|
|
1363
|
+
if (callee.type === "Identifier" && ROUTER_FACTORY_NAMES.includes(callee.name)) {
|
|
1364
|
+
const firstArg = decl.init.arguments[0];
|
|
1365
|
+
if (firstArg?.type === "ArrayExpression") {
|
|
1366
|
+
const parsed = parseRoutesArray$1(firstArg, routesFileDir, warnings);
|
|
1367
|
+
routes.push(...parsed);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
if (node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration") for (const decl of node.declaration.declarations) {
|
|
1373
|
+
if (decl.init?.type === "CallExpression") {
|
|
1374
|
+
const callee = decl.init.callee;
|
|
1375
|
+
if (callee.type === "Identifier" && ROUTER_FACTORY_NAMES.includes(callee.name)) {
|
|
1376
|
+
const firstArg = decl.init.arguments[0];
|
|
1377
|
+
if (firstArg?.type === "ArrayExpression") {
|
|
1378
|
+
const parsed = parseRoutesArray$1(firstArg, routesFileDir, warnings);
|
|
1379
|
+
routes.push(...parsed);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
if (decl.id.type === "Identifier" && decl.id.name === "routes" && decl.init?.type === "ArrayExpression") {
|
|
1384
|
+
const parsed = parseRoutesArray$1(decl.init, routesFileDir, warnings);
|
|
1385
|
+
routes.push(...parsed);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
if (node.type === "VariableDeclaration") {
|
|
1389
|
+
for (const decl of node.declarations) if (decl.id.type === "Identifier" && decl.id.name === "routes" && decl.init?.type === "ArrayExpression") {
|
|
1390
|
+
const parsed = parseRoutesArray$1(decl.init, routesFileDir, warnings);
|
|
1391
|
+
routes.push(...parsed);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
if (node.type === "ExportDefaultDeclaration" && node.declaration.type === "ArrayExpression") {
|
|
1395
|
+
const parsed = parseRoutesArray$1(node.declaration, routesFileDir, warnings);
|
|
1396
|
+
routes.push(...parsed);
|
|
1397
|
+
}
|
|
1398
|
+
if (node.type === "ExportDefaultDeclaration" && node.declaration.type === "TSSatisfiesExpression" && node.declaration.expression.type === "ArrayExpression") {
|
|
1399
|
+
const parsed = parseRoutesArray$1(node.declaration.expression, routesFileDir, warnings);
|
|
1400
|
+
routes.push(...parsed);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
if (routes.length === 0) warnings.push("No routes found. Supported patterns: 'createBrowserRouter([...])', 'export const routes = [...]', or 'export default [...]'");
|
|
1404
|
+
return {
|
|
1405
|
+
routes,
|
|
1406
|
+
warnings
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Parse an array expression containing route objects
|
|
1411
|
+
*/
|
|
1412
|
+
function parseRoutesArray$1(arrayNode, baseDir, warnings) {
|
|
1413
|
+
const routes = [];
|
|
1414
|
+
for (const element of arrayNode.elements) {
|
|
1415
|
+
if (!element) continue;
|
|
1416
|
+
if (element.type === "SpreadElement") {
|
|
1417
|
+
const loc = element.loc ? ` at line ${element.loc.start.line}` : "";
|
|
1418
|
+
warnings.push(`Spread operator detected${loc}. Routes from spread cannot be statically analyzed.`);
|
|
1419
|
+
continue;
|
|
1420
|
+
}
|
|
1421
|
+
if (element.type === "ObjectExpression") {
|
|
1422
|
+
const route = parseRouteObject$1(element, baseDir, warnings);
|
|
1423
|
+
if (route) routes.push(route);
|
|
1424
|
+
} else {
|
|
1425
|
+
const loc = element.loc ? ` at line ${element.loc.start.line}` : "";
|
|
1426
|
+
warnings.push(`Non-object route element (${element.type})${loc}. Only object literals can be statically analyzed.`);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
return routes;
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Parse a single route object expression
|
|
1433
|
+
*/
|
|
1434
|
+
function parseRouteObject$1(objectNode, baseDir, warnings) {
|
|
1435
|
+
const route = { path: "" };
|
|
1436
|
+
let isIndexRoute = false;
|
|
1437
|
+
let hasPath = false;
|
|
1438
|
+
for (const prop of objectNode.properties) {
|
|
1439
|
+
if (prop.type !== "ObjectProperty") continue;
|
|
1440
|
+
if (prop.key.type !== "Identifier") continue;
|
|
1441
|
+
switch (prop.key.name) {
|
|
1442
|
+
case "path":
|
|
1443
|
+
if (prop.value.type === "StringLiteral") {
|
|
1444
|
+
route.path = prop.value.value;
|
|
1445
|
+
hasPath = true;
|
|
1446
|
+
} else {
|
|
1447
|
+
const loc = prop.loc ? ` at line ${prop.loc.start.line}` : "";
|
|
1448
|
+
warnings.push(`Dynamic path value (${prop.value.type})${loc}. Only string literal paths can be statically analyzed.`);
|
|
1449
|
+
}
|
|
1450
|
+
break;
|
|
1451
|
+
case "index":
|
|
1452
|
+
if (prop.value.type === "BooleanLiteral" && prop.value.value === true) isIndexRoute = true;
|
|
1453
|
+
break;
|
|
1454
|
+
case "element":
|
|
1455
|
+
route.component = extractComponentFromJSX(prop.value, warnings);
|
|
1456
|
+
break;
|
|
1457
|
+
case "Component":
|
|
1458
|
+
if (prop.value.type === "Identifier") route.component = prop.value.name;
|
|
1459
|
+
break;
|
|
1460
|
+
case "lazy": {
|
|
1461
|
+
const lazyPath = extractLazyImportPath(prop.value, baseDir, warnings);
|
|
1462
|
+
if (lazyPath) route.component = lazyPath;
|
|
1463
|
+
break;
|
|
1464
|
+
}
|
|
1465
|
+
case "children":
|
|
1466
|
+
if (prop.value.type === "ArrayExpression") route.children = parseRoutesArray$1(prop.value, baseDir, warnings);
|
|
1467
|
+
break;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
if (isIndexRoute) {
|
|
1471
|
+
route.path = "";
|
|
1472
|
+
return route;
|
|
1473
|
+
}
|
|
1474
|
+
if (!hasPath && !isIndexRoute) {
|
|
1475
|
+
if (route.children && route.children.length > 0) {
|
|
1476
|
+
route.path = "";
|
|
1477
|
+
return route;
|
|
1478
|
+
}
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1481
|
+
return route;
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Extract component name from JSX element
|
|
1485
|
+
* element: <Dashboard /> -> "Dashboard"
|
|
1486
|
+
*/
|
|
1487
|
+
function extractComponentFromJSX(node, warnings) {
|
|
1488
|
+
if (node.type === "JSXElement") {
|
|
1489
|
+
const openingElement = node.openingElement;
|
|
1490
|
+
if (openingElement?.name?.type === "JSXIdentifier") return openingElement.name.name;
|
|
1491
|
+
if (openingElement?.name?.type === "JSXMemberExpression") {
|
|
1492
|
+
const parts = [];
|
|
1493
|
+
let current = openingElement.name;
|
|
1494
|
+
while (current) {
|
|
1495
|
+
if (current.type === "JSXIdentifier") {
|
|
1496
|
+
parts.unshift(current.name);
|
|
1497
|
+
break;
|
|
1498
|
+
}
|
|
1499
|
+
if (current.type === "JSXMemberExpression") {
|
|
1500
|
+
if (current.property?.type === "JSXIdentifier") parts.unshift(current.property.name);
|
|
1501
|
+
current = current.object;
|
|
1502
|
+
} else break;
|
|
1503
|
+
}
|
|
1504
|
+
return parts.join(".");
|
|
1505
|
+
}
|
|
1506
|
+
if (node.children && node.children.length > 0 && openingElement?.name?.type === "JSXIdentifier") {
|
|
1507
|
+
const wrapperName = openingElement.name.name;
|
|
1508
|
+
for (const child of node.children) if (child.type === "JSXElement") {
|
|
1509
|
+
if (extractComponentFromJSX(child, warnings)) {
|
|
1510
|
+
warnings.push(`Wrapper component detected: <${wrapperName}>. Using wrapper name for screen.`);
|
|
1511
|
+
return wrapperName;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
if (node.type === "JSXFragment") return;
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* Extract import path from lazy function
|
|
1520
|
+
* lazy: () => import("./pages/Dashboard") -> resolved path
|
|
1521
|
+
*/
|
|
1522
|
+
function extractLazyImportPath(node, baseDir, warnings) {
|
|
1523
|
+
if (node.type === "ArrowFunctionExpression") {
|
|
1524
|
+
const body = node.body;
|
|
1525
|
+
if (body.type === "CallExpression" && body.callee.type === "Import") {
|
|
1526
|
+
if (body.arguments[0]?.type === "StringLiteral") return resolveImportPath(body.arguments[0].value, baseDir);
|
|
1527
|
+
const loc$1 = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1528
|
+
warnings.push(`Lazy import with dynamic path${loc$1}. Only string literal imports can be statically analyzed.`);
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
|
|
1533
|
+
warnings.push(`Unrecognized lazy pattern (${node.type})${loc}. Expected arrow function with import().`);
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Detect if content is React Router based on patterns
|
|
1537
|
+
*/
|
|
1538
|
+
function isReactRouterContent(content) {
|
|
1539
|
+
if (content.includes("createBrowserRouter") || content.includes("createHashRouter") || content.includes("createMemoryRouter") || content.includes("RouteObject")) return true;
|
|
1540
|
+
if (/element:\s*</.test(content)) return true;
|
|
1541
|
+
if (/Component:\s*[A-Z]/.test(content)) return true;
|
|
1542
|
+
return false;
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Detect if content is Vue Router based on patterns
|
|
1546
|
+
*/
|
|
1547
|
+
function isVueRouterContent(content) {
|
|
1548
|
+
if (content.includes("RouteRecordRaw") || content.includes("vue-router") || content.includes(".vue")) return true;
|
|
1549
|
+
return false;
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Detect router type from file content
|
|
1553
|
+
*/
|
|
1554
|
+
function detectRouterType(content) {
|
|
1555
|
+
if (isTanStackRouterContent(content)) return "tanstack-router";
|
|
1556
|
+
if (isReactRouterContent(content)) return "react-router";
|
|
1557
|
+
if (isVueRouterContent(content)) return "vue-router";
|
|
1558
|
+
return "unknown";
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
//#endregion
|
|
1562
|
+
//#region src/utils/vueRouterParser.ts
|
|
1563
|
+
/**
|
|
1564
|
+
* Parse Vue Router configuration file and extract routes
|
|
1565
|
+
*/
|
|
1566
|
+
function parseVueRouterConfig(filePath, preloadedContent) {
|
|
1567
|
+
const absolutePath = resolve(filePath);
|
|
1568
|
+
const routesFileDir = dirname(absolutePath);
|
|
1569
|
+
const warnings = [];
|
|
1570
|
+
let content;
|
|
1571
|
+
if (preloadedContent !== void 0) content = preloadedContent;
|
|
1572
|
+
else try {
|
|
1573
|
+
content = readFileSync(absolutePath, "utf-8");
|
|
1574
|
+
} catch (error) {
|
|
1575
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1576
|
+
throw new Error(`Failed to read routes file "${absolutePath}": ${message}`);
|
|
1577
|
+
}
|
|
1578
|
+
let ast;
|
|
1579
|
+
try {
|
|
1580
|
+
ast = parse(content, {
|
|
1581
|
+
sourceType: "module",
|
|
1582
|
+
plugins: ["typescript", "jsx"]
|
|
1583
|
+
});
|
|
1584
|
+
} catch (error) {
|
|
1585
|
+
if (error instanceof SyntaxError) throw new Error(`Syntax error in routes file "${absolutePath}": ${error.message}`);
|
|
1586
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1587
|
+
throw new Error(`Failed to parse routes file "${absolutePath}": ${message}`);
|
|
1588
|
+
}
|
|
1589
|
+
const routes = [];
|
|
1590
|
+
for (const node of ast.program.body) {
|
|
1591
|
+
if (node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration") {
|
|
1592
|
+
for (const decl of node.declaration.declarations) if (decl.id.type === "Identifier" && decl.id.name === "routes" && decl.init?.type === "ArrayExpression") {
|
|
1593
|
+
const parsed = parseRoutesArray(decl.init, routesFileDir, warnings);
|
|
1594
|
+
routes.push(...parsed);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
if (node.type === "VariableDeclaration") {
|
|
1598
|
+
for (const decl of node.declarations) if (decl.id.type === "Identifier" && decl.id.name === "routes" && decl.init?.type === "ArrayExpression") {
|
|
1599
|
+
const parsed = parseRoutesArray(decl.init, routesFileDir, warnings);
|
|
1600
|
+
routes.push(...parsed);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
if (node.type === "ExportDefaultDeclaration" && node.declaration.type === "ArrayExpression") {
|
|
1604
|
+
const parsed = parseRoutesArray(node.declaration, routesFileDir, warnings);
|
|
1605
|
+
routes.push(...parsed);
|
|
1606
|
+
}
|
|
1607
|
+
if (node.type === "ExportDefaultDeclaration" && node.declaration.type === "TSSatisfiesExpression" && node.declaration.expression.type === "ArrayExpression") {
|
|
1608
|
+
const parsed = parseRoutesArray(node.declaration.expression, routesFileDir, warnings);
|
|
1609
|
+
routes.push(...parsed);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
if (routes.length === 0) warnings.push("No routes array found. Supported patterns: 'export const routes = [...]', 'export default [...]', or 'export default [...] satisfies RouteRecordRaw[]'");
|
|
1613
|
+
return {
|
|
1614
|
+
routes,
|
|
1615
|
+
warnings
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Parse an array expression containing route objects
|
|
1620
|
+
*/
|
|
1621
|
+
function parseRoutesArray(arrayNode, baseDir, warnings) {
|
|
1622
|
+
const routes = [];
|
|
1623
|
+
for (const element of arrayNode.elements) {
|
|
1624
|
+
if (!element) continue;
|
|
1625
|
+
if (element.type === "SpreadElement") {
|
|
1626
|
+
const loc = element.loc ? ` at line ${element.loc.start.line}` : "";
|
|
1627
|
+
warnings.push(`Spread operator detected${loc}. Routes from spread cannot be statically analyzed.`);
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
if (element.type === "ObjectExpression") {
|
|
1631
|
+
const route = parseRouteObject(element, baseDir, warnings);
|
|
1632
|
+
if (route) routes.push(route);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
return routes;
|
|
1636
|
+
}
|
|
1637
|
+
/**
|
|
1638
|
+
* Parse a single route object expression
|
|
1639
|
+
*/
|
|
1640
|
+
function parseRouteObject(objectNode, baseDir, warnings) {
|
|
1641
|
+
const route = { path: "" };
|
|
1642
|
+
for (const prop of objectNode.properties) {
|
|
1643
|
+
if (prop.type !== "ObjectProperty") continue;
|
|
1644
|
+
if (prop.key.type !== "Identifier") continue;
|
|
1645
|
+
switch (prop.key.name) {
|
|
1646
|
+
case "path":
|
|
1647
|
+
if (prop.value.type === "StringLiteral") route.path = prop.value.value;
|
|
1648
|
+
break;
|
|
1649
|
+
case "name":
|
|
1650
|
+
if (prop.value.type === "StringLiteral") route.name = prop.value.value;
|
|
1651
|
+
break;
|
|
1652
|
+
case "redirect":
|
|
1653
|
+
if (prop.value.type === "StringLiteral") route.redirect = prop.value.value;
|
|
1654
|
+
break;
|
|
1655
|
+
case "component":
|
|
1656
|
+
route.component = extractComponentPath(prop.value, baseDir);
|
|
1657
|
+
break;
|
|
1658
|
+
case "children":
|
|
1659
|
+
if (prop.value.type === "ArrayExpression") route.children = parseRoutesArray(prop.value, baseDir, warnings);
|
|
1660
|
+
break;
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
if (!route.path) return null;
|
|
1664
|
+
return route;
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Extract component path from various component definitions
|
|
1668
|
+
*/
|
|
1669
|
+
function extractComponentPath(node, baseDir) {
|
|
1670
|
+
if (node.type === "Identifier") return;
|
|
1671
|
+
if (node.type === "ArrowFunctionExpression") {
|
|
1672
|
+
const body = node.body;
|
|
1673
|
+
if (body.type === "CallExpression" && body.callee.type === "Import") {
|
|
1674
|
+
if (body.arguments[0]?.type === "StringLiteral") return resolveImportPath(body.arguments[0].value, baseDir);
|
|
1675
|
+
}
|
|
1676
|
+
if (body.type === "CallExpression" && body.callee.type === "Import") {
|
|
1677
|
+
for (const arg of body.arguments) if (arg.type === "StringLiteral") return resolveImportPath(arg.value, baseDir);
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
934
1682
|
//#endregion
|
|
935
1683
|
//#region src/commands/generate.ts
|
|
936
1684
|
const generateCommand = define({
|
|
@@ -967,92 +1715,229 @@ const generateCommand = define({
|
|
|
967
1715
|
const dryRun = ctx.values.dryRun ?? false;
|
|
968
1716
|
const force = ctx.values.force ?? false;
|
|
969
1717
|
const interactive = ctx.values.interactive ?? false;
|
|
970
|
-
if (!config.routesPattern) {
|
|
1718
|
+
if (!config.routesPattern && !config.routesFile) {
|
|
971
1719
|
logger.errorWithHelp(ERRORS.ROUTES_PATTERN_MISSING);
|
|
972
1720
|
process.exit(1);
|
|
973
1721
|
}
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1722
|
+
if (config.routesFile) {
|
|
1723
|
+
await generateFromRoutesFile(config.routesFile, cwd, {
|
|
1724
|
+
dryRun,
|
|
1725
|
+
force,
|
|
1726
|
+
interactive
|
|
1727
|
+
});
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
await generateFromRoutesPattern(config.routesPattern, cwd, {
|
|
1731
|
+
dryRun,
|
|
1732
|
+
force,
|
|
1733
|
+
interactive,
|
|
978
1734
|
ignore: config.ignore
|
|
979
1735
|
});
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1736
|
+
}
|
|
1737
|
+
});
|
|
1738
|
+
/**
|
|
1739
|
+
* Generate screen.meta.ts files from a router config file (Vue Router or React Router)
|
|
1740
|
+
*/
|
|
1741
|
+
async function generateFromRoutesFile(routesFile, cwd, options) {
|
|
1742
|
+
const { dryRun, force, interactive } = options;
|
|
1743
|
+
const absoluteRoutesFile = resolve(cwd, routesFile);
|
|
1744
|
+
if (!existsSync(absoluteRoutesFile)) {
|
|
1745
|
+
logger.errorWithHelp(ERRORS.ROUTES_FILE_NOT_FOUND(routesFile));
|
|
1746
|
+
process.exit(1);
|
|
1747
|
+
}
|
|
1748
|
+
const routerType = detectRouterType(readFileSync(absoluteRoutesFile, "utf-8"));
|
|
1749
|
+
const routerTypeDisplay = routerType === "tanstack-router" ? "TanStack Router" : routerType === "react-router" ? "React Router" : routerType === "vue-router" ? "Vue Router" : "unknown";
|
|
1750
|
+
logger.info(`Parsing routes from ${logger.path(routesFile)} (${routerTypeDisplay})...`);
|
|
1751
|
+
logger.blank();
|
|
1752
|
+
let parseResult;
|
|
1753
|
+
try {
|
|
1754
|
+
if (routerType === "tanstack-router") parseResult = parseTanStackRouterConfig(absoluteRoutesFile);
|
|
1755
|
+
else if (routerType === "react-router") parseResult = parseReactRouterConfig(absoluteRoutesFile);
|
|
1756
|
+
else parseResult = parseVueRouterConfig(absoluteRoutesFile);
|
|
1757
|
+
} catch (error) {
|
|
1758
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1759
|
+
logger.errorWithHelp(ERRORS.ROUTES_FILE_PARSE_ERROR(routesFile, message));
|
|
1760
|
+
process.exit(1);
|
|
1761
|
+
}
|
|
1762
|
+
for (const warning of parseResult.warnings) logger.warn(warning);
|
|
1763
|
+
const flatRoutes = flattenRoutes(parseResult.routes);
|
|
1764
|
+
if (flatRoutes.length === 0) {
|
|
1765
|
+
logger.warn("No routes found in the config file");
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
logger.log(`Found ${flatRoutes.length} routes`);
|
|
1769
|
+
logger.blank();
|
|
1770
|
+
let created = 0;
|
|
1771
|
+
let skipped = 0;
|
|
1772
|
+
for (const route of flatRoutes) {
|
|
1773
|
+
const metaPath = determineMetaPath(route, cwd);
|
|
1774
|
+
const absoluteMetaPath = resolve(cwd, metaPath);
|
|
1775
|
+
if (!force && existsSync(absoluteMetaPath)) {
|
|
1776
|
+
if (!interactive) {
|
|
1777
|
+
skipped++;
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
logger.itemWarn(`Exists: ${logger.path(metaPath)} (use --force to overwrite)`);
|
|
1781
|
+
skipped++;
|
|
1782
|
+
continue;
|
|
983
1783
|
}
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
const
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
if (!interactive) {
|
|
994
|
-
skipped++;
|
|
995
|
-
continue;
|
|
996
|
-
}
|
|
997
|
-
logger.itemWarn(`Exists: ${logger.path(metaPath)} (use --force to overwrite)`);
|
|
1784
|
+
const screenMeta = {
|
|
1785
|
+
id: route.screenId,
|
|
1786
|
+
title: route.screenTitle,
|
|
1787
|
+
route: route.fullPath
|
|
1788
|
+
};
|
|
1789
|
+
if (interactive) {
|
|
1790
|
+
const result = await promptForScreen(route.fullPath, screenMeta);
|
|
1791
|
+
if (result.skip) {
|
|
1792
|
+
logger.itemWarn(`Skipped: ${logger.path(metaPath)}`);
|
|
998
1793
|
skipped++;
|
|
999
1794
|
continue;
|
|
1000
1795
|
}
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1796
|
+
const content = generateScreenMetaContent(result.meta, {
|
|
1797
|
+
owner: result.owner,
|
|
1798
|
+
tags: result.tags
|
|
1799
|
+
});
|
|
1800
|
+
if (dryRun) {
|
|
1801
|
+
logDryRunOutput(metaPath, result.meta, result.owner, result.tags);
|
|
1802
|
+
created++;
|
|
1803
|
+
} else if (safeWriteFile(absoluteMetaPath, metaPath, content)) created++;
|
|
1804
|
+
} else {
|
|
1805
|
+
const content = generateScreenMetaContent(screenMeta);
|
|
1806
|
+
if (dryRun) {
|
|
1807
|
+
logDryRunOutput(metaPath, screenMeta);
|
|
1808
|
+
created++;
|
|
1809
|
+
} else if (safeWriteFile(absoluteMetaPath, metaPath, content)) created++;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
logSummary(created, skipped, dryRun);
|
|
1813
|
+
}
|
|
1814
|
+
/**
|
|
1815
|
+
* Generate screen.meta.ts files from route files matching a glob pattern
|
|
1816
|
+
*/
|
|
1817
|
+
async function generateFromRoutesPattern(routesPattern, cwd, options) {
|
|
1818
|
+
const { dryRun, force, interactive, ignore } = options;
|
|
1819
|
+
logger.info("Scanning for route files...");
|
|
1820
|
+
logger.blank();
|
|
1821
|
+
const routeFiles = await glob(routesPattern, {
|
|
1822
|
+
cwd,
|
|
1823
|
+
ignore
|
|
1824
|
+
});
|
|
1825
|
+
if (routeFiles.length === 0) {
|
|
1826
|
+
logger.warn(`No route files found matching: ${routesPattern}`);
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
logger.log(`Found ${routeFiles.length} route files`);
|
|
1830
|
+
logger.blank();
|
|
1831
|
+
let created = 0;
|
|
1832
|
+
let skipped = 0;
|
|
1833
|
+
for (const routeFile of routeFiles) {
|
|
1834
|
+
const routeDir = dirname(routeFile);
|
|
1835
|
+
const metaPath = join(routeDir, "screen.meta.ts");
|
|
1836
|
+
const absoluteMetaPath = join(cwd, metaPath);
|
|
1837
|
+
if (!force && existsSync(absoluteMetaPath)) {
|
|
1838
|
+
if (!interactive) {
|
|
1839
|
+
skipped++;
|
|
1840
|
+
continue;
|
|
1841
|
+
}
|
|
1842
|
+
logger.itemWarn(`Exists: ${logger.path(metaPath)} (use --force to overwrite)`);
|
|
1843
|
+
skipped++;
|
|
1844
|
+
continue;
|
|
1845
|
+
}
|
|
1846
|
+
const screenMeta = inferScreenMeta(routeDir, routesPattern);
|
|
1847
|
+
if (interactive) {
|
|
1848
|
+
const result = await promptForScreen(routeFile, screenMeta);
|
|
1849
|
+
if (result.skip) {
|
|
1850
|
+
logger.itemWarn(`Skipped: ${logger.path(metaPath)}`);
|
|
1851
|
+
skipped++;
|
|
1852
|
+
continue;
|
|
1037
1853
|
}
|
|
1038
|
-
|
|
1854
|
+
const content = generateScreenMetaContent(result.meta, {
|
|
1855
|
+
owner: result.owner,
|
|
1856
|
+
tags: result.tags
|
|
1857
|
+
});
|
|
1858
|
+
if (dryRun) {
|
|
1859
|
+
logDryRunOutput(metaPath, result.meta, result.owner, result.tags);
|
|
1860
|
+
created++;
|
|
1861
|
+
} else if (safeWriteFile(absoluteMetaPath, metaPath, content)) created++;
|
|
1862
|
+
} else {
|
|
1863
|
+
const content = generateScreenMetaContent(screenMeta);
|
|
1864
|
+
if (dryRun) {
|
|
1865
|
+
logDryRunOutput(metaPath, screenMeta);
|
|
1866
|
+
created++;
|
|
1867
|
+
} else if (safeWriteFile(absoluteMetaPath, metaPath, content)) created++;
|
|
1039
1868
|
}
|
|
1869
|
+
}
|
|
1870
|
+
logSummary(created, skipped, dryRun);
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* Determine where to place screen.meta.ts for a route
|
|
1874
|
+
*/
|
|
1875
|
+
function determineMetaPath(route, cwd) {
|
|
1876
|
+
if (route.componentPath) {
|
|
1877
|
+
const relativePath = relative(cwd, dirname(route.componentPath));
|
|
1878
|
+
if (!relativePath.startsWith("..")) return join(relativePath, "screen.meta.ts");
|
|
1879
|
+
}
|
|
1880
|
+
return join("src", "screens", route.screenId.replace(/\./g, "/"), "screen.meta.ts");
|
|
1881
|
+
}
|
|
1882
|
+
/**
|
|
1883
|
+
* Ensure the directory for a file exists
|
|
1884
|
+
*/
|
|
1885
|
+
function ensureDirectoryExists(filePath) {
|
|
1886
|
+
const dir = dirname(filePath);
|
|
1887
|
+
if (!existsSync(dir)) try {
|
|
1888
|
+
mkdirSync(dir, { recursive: true });
|
|
1889
|
+
} catch (error) {
|
|
1890
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1891
|
+
throw new Error(`Failed to create directory "${dir}": ${message}`);
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
/**
|
|
1895
|
+
* Safely write a file with error handling
|
|
1896
|
+
* Returns true if successful, false if failed
|
|
1897
|
+
*/
|
|
1898
|
+
function safeWriteFile(absolutePath, relativePath, content) {
|
|
1899
|
+
try {
|
|
1900
|
+
ensureDirectoryExists(absolutePath);
|
|
1901
|
+
writeFileSync(absolutePath, content);
|
|
1902
|
+
logger.itemSuccess(`Created: ${logger.path(relativePath)}`);
|
|
1903
|
+
return true;
|
|
1904
|
+
} catch (error) {
|
|
1905
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1906
|
+
logger.itemError(`Failed to create ${logger.path(relativePath)}: ${message}`);
|
|
1907
|
+
return false;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
/**
|
|
1911
|
+
* Log dry run output for a screen
|
|
1912
|
+
*/
|
|
1913
|
+
function logDryRunOutput(metaPath, meta, owner, tags) {
|
|
1914
|
+
logger.step(`Would create: ${logger.path(metaPath)}`);
|
|
1915
|
+
logger.log(` ${logger.dim(`id: "${meta.id}"`)}`);
|
|
1916
|
+
logger.log(` ${logger.dim(`title: "${meta.title}"`)}`);
|
|
1917
|
+
logger.log(` ${logger.dim(`route: "${meta.route}"`)}`);
|
|
1918
|
+
if (owner && owner.length > 0) logger.log(` ${logger.dim(`owner: [${owner.map((o) => `"${o}"`).join(", ")}]`)}`);
|
|
1919
|
+
if (tags && tags.length > 0) logger.log(` ${logger.dim(`tags: [${tags.map((t) => `"${t}"`).join(", ")}]`)}`);
|
|
1920
|
+
logger.blank();
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* Log summary after generation
|
|
1924
|
+
*/
|
|
1925
|
+
function logSummary(created, skipped, dryRun) {
|
|
1926
|
+
logger.blank();
|
|
1927
|
+
if (dryRun) {
|
|
1928
|
+
logger.info(`Would create ${created} files (${skipped} already exist)`);
|
|
1040
1929
|
logger.blank();
|
|
1041
|
-
|
|
1042
|
-
|
|
1930
|
+
logger.log(`Run without ${logger.code("--dry-run")} to create files`);
|
|
1931
|
+
} else {
|
|
1932
|
+
logger.done(`Created ${created} files (${skipped} skipped)`);
|
|
1933
|
+
if (created > 0) {
|
|
1043
1934
|
logger.blank();
|
|
1044
|
-
logger.log(
|
|
1045
|
-
|
|
1046
|
-
logger.
|
|
1047
|
-
if (created > 0) {
|
|
1048
|
-
logger.blank();
|
|
1049
|
-
logger.log(logger.bold("Next steps:"));
|
|
1050
|
-
logger.log(" 1. Review and customize the generated screen.meta.ts files");
|
|
1051
|
-
logger.log(` 2. Run ${logger.code("screenbook dev")} to view your screen catalog`);
|
|
1052
|
-
}
|
|
1935
|
+
logger.log(logger.bold("Next steps:"));
|
|
1936
|
+
logger.log(" 1. Review and customize the generated screen.meta.ts files");
|
|
1937
|
+
logger.log(` 2. Run ${logger.code("screenbook dev")} to view your screen catalog`);
|
|
1053
1938
|
}
|
|
1054
1939
|
}
|
|
1055
|
-
}
|
|
1940
|
+
}
|
|
1056
1941
|
/**
|
|
1057
1942
|
* Parse comma-separated string into array
|
|
1058
1943
|
*/
|
|
@@ -1694,7 +2579,7 @@ const lintCommand = define({
|
|
|
1694
2579
|
const cwd = process.cwd();
|
|
1695
2580
|
const adoption = config.adoption ?? { mode: "full" };
|
|
1696
2581
|
let hasWarnings = false;
|
|
1697
|
-
if (!config.routesPattern) {
|
|
2582
|
+
if (!config.routesPattern && !config.routesFile) {
|
|
1698
2583
|
logger.errorWithHelp(ERRORS.ROUTES_PATTERN_MISSING);
|
|
1699
2584
|
process.exit(1);
|
|
1700
2585
|
}
|
|
@@ -1705,6 +2590,10 @@ const lintCommand = define({
|
|
|
1705
2590
|
if (adoption.minimumCoverage != null) logger.log(`Minimum coverage: ${adoption.minimumCoverage}%`);
|
|
1706
2591
|
}
|
|
1707
2592
|
logger.blank();
|
|
2593
|
+
if (config.routesFile) {
|
|
2594
|
+
await lintRoutesFile(config.routesFile, cwd, config, adoption, ctx.values.allowCycles ?? false, ctx.values.strict ?? false);
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
1708
2597
|
let routeFiles = await glob(config.routesPattern, {
|
|
1709
2598
|
cwd,
|
|
1710
2599
|
ignore: config.ignore
|
|
@@ -1809,6 +2698,9 @@ const lintCommand = define({
|
|
|
1809
2698
|
} else if (error instanceof Error) {
|
|
1810
2699
|
logger.warn(`Failed to analyze screens.json: ${error.message}`);
|
|
1811
2700
|
hasWarnings = true;
|
|
2701
|
+
} else {
|
|
2702
|
+
logger.warn(`Failed to analyze screens.json: ${String(error)}`);
|
|
2703
|
+
hasWarnings = true;
|
|
1812
2704
|
}
|
|
1813
2705
|
}
|
|
1814
2706
|
if (hasWarnings) {
|
|
@@ -1818,6 +2710,188 @@ const lintCommand = define({
|
|
|
1818
2710
|
}
|
|
1819
2711
|
});
|
|
1820
2712
|
/**
|
|
2713
|
+
* Lint screen.meta.ts coverage for routesFile mode (config-based routing)
|
|
2714
|
+
*/
|
|
2715
|
+
async function lintRoutesFile(routesFile, cwd, config, adoption, allowCycles, strict) {
|
|
2716
|
+
let hasWarnings = false;
|
|
2717
|
+
const absoluteRoutesFile = resolve(cwd, routesFile);
|
|
2718
|
+
if (!existsSync(absoluteRoutesFile)) {
|
|
2719
|
+
logger.errorWithHelp(ERRORS.ROUTES_FILE_NOT_FOUND(routesFile));
|
|
2720
|
+
process.exit(1);
|
|
2721
|
+
}
|
|
2722
|
+
logger.log(`Parsing routes from ${logger.path(routesFile)}...`);
|
|
2723
|
+
logger.blank();
|
|
2724
|
+
let flatRoutes;
|
|
2725
|
+
try {
|
|
2726
|
+
const content = readFileSync(absoluteRoutesFile, "utf-8");
|
|
2727
|
+
const routerType = detectRouterType(content);
|
|
2728
|
+
let parseResult;
|
|
2729
|
+
if (routerType === "tanstack-router") parseResult = parseTanStackRouterConfig(absoluteRoutesFile, content);
|
|
2730
|
+
else if (routerType === "react-router") parseResult = parseReactRouterConfig(absoluteRoutesFile, content);
|
|
2731
|
+
else parseResult = parseVueRouterConfig(absoluteRoutesFile, content);
|
|
2732
|
+
for (const warning of parseResult.warnings) {
|
|
2733
|
+
logger.warn(warning);
|
|
2734
|
+
hasWarnings = true;
|
|
2735
|
+
}
|
|
2736
|
+
flatRoutes = flattenRoutes(parseResult.routes);
|
|
2737
|
+
} catch (error) {
|
|
2738
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2739
|
+
logger.errorWithHelp(ERRORS.ROUTES_FILE_PARSE_ERROR(routesFile, message));
|
|
2740
|
+
process.exit(1);
|
|
2741
|
+
}
|
|
2742
|
+
if (flatRoutes.length === 0) {
|
|
2743
|
+
logger.warn("No routes found in the config file");
|
|
2744
|
+
return hasWarnings;
|
|
2745
|
+
}
|
|
2746
|
+
const metaFiles = await glob(config.metaPattern, {
|
|
2747
|
+
cwd,
|
|
2748
|
+
ignore: config.ignore
|
|
2749
|
+
});
|
|
2750
|
+
const metaDirs = /* @__PURE__ */ new Set();
|
|
2751
|
+
const metaDirsByName = /* @__PURE__ */ new Map();
|
|
2752
|
+
for (const metaFile of metaFiles) {
|
|
2753
|
+
const dir = dirname(metaFile);
|
|
2754
|
+
metaDirs.add(dir);
|
|
2755
|
+
const baseName = dir.split("/").pop()?.toLowerCase() || "";
|
|
2756
|
+
if (baseName) metaDirsByName.set(baseName, dir);
|
|
2757
|
+
}
|
|
2758
|
+
const missingMeta = [];
|
|
2759
|
+
const covered = [];
|
|
2760
|
+
for (const route of flatRoutes) {
|
|
2761
|
+
if (route.componentPath?.endsWith("Layout")) continue;
|
|
2762
|
+
let matched = false;
|
|
2763
|
+
const metaPath = determineMetaDir(route, cwd);
|
|
2764
|
+
if (metaDirs.has(metaPath)) matched = true;
|
|
2765
|
+
if (!matched && route.componentPath) {
|
|
2766
|
+
const componentName = route.componentPath.toLowerCase();
|
|
2767
|
+
if (metaDirsByName.has(componentName)) matched = true;
|
|
2768
|
+
if (!matched) {
|
|
2769
|
+
const parts = route.componentPath.split(/(?=[A-Z])/);
|
|
2770
|
+
const lastPart = parts[parts.length - 1];
|
|
2771
|
+
if (parts.length > 1 && lastPart) {
|
|
2772
|
+
if (metaDirsByName.has(lastPart.toLowerCase())) matched = true;
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
if (!matched) {
|
|
2777
|
+
const screenPath = route.screenId.replace(/\./g, "/");
|
|
2778
|
+
for (const dir of metaDirs) if (dir.endsWith(screenPath)) {
|
|
2779
|
+
matched = true;
|
|
2780
|
+
break;
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
if (matched) covered.push(route);
|
|
2784
|
+
else missingMeta.push(route);
|
|
2785
|
+
}
|
|
2786
|
+
const total = covered.length + missingMeta.length;
|
|
2787
|
+
const coveredCount = covered.length;
|
|
2788
|
+
const missingCount = missingMeta.length;
|
|
2789
|
+
const coveragePercent = Math.round(coveredCount / total * 100);
|
|
2790
|
+
logger.log(`Found ${total} routes`);
|
|
2791
|
+
logger.log(`Coverage: ${coveredCount}/${total} (${coveragePercent}%)`);
|
|
2792
|
+
logger.blank();
|
|
2793
|
+
const minimumCoverage = adoption.minimumCoverage ?? 100;
|
|
2794
|
+
const passedCoverage = coveragePercent >= minimumCoverage;
|
|
2795
|
+
if (missingCount > 0) {
|
|
2796
|
+
logger.log(`Missing screen.meta.ts (${missingCount} routes):`);
|
|
2797
|
+
logger.blank();
|
|
2798
|
+
for (const route of missingMeta) {
|
|
2799
|
+
const suggestedMetaPath = determineSuggestedMetaPath(route, cwd);
|
|
2800
|
+
logger.itemError(`${route.fullPath} ${logger.dim(`(${route.screenId})`)}`);
|
|
2801
|
+
logger.log(` ${logger.dim("→")} ${logger.path(suggestedMetaPath)}`);
|
|
2802
|
+
}
|
|
2803
|
+
logger.blank();
|
|
2804
|
+
}
|
|
2805
|
+
if (!passedCoverage) {
|
|
2806
|
+
logger.error(`Lint failed: Coverage ${coveragePercent}% is below minimum ${minimumCoverage}%`);
|
|
2807
|
+
process.exit(1);
|
|
2808
|
+
} else if (missingCount > 0) {
|
|
2809
|
+
logger.success(`Coverage ${coveragePercent}% meets minimum ${minimumCoverage}%`);
|
|
2810
|
+
if (adoption.mode === "progressive") logger.log(` ${logger.dim("Tip:")} Increase minimumCoverage in config to gradually improve coverage`);
|
|
2811
|
+
} else logger.done("All routes have screen.meta.ts files");
|
|
2812
|
+
const screensPath = join(cwd, config.outDir, "screens.json");
|
|
2813
|
+
if (existsSync(screensPath)) try {
|
|
2814
|
+
const content = readFileSync(screensPath, "utf-8");
|
|
2815
|
+
const screens = JSON.parse(content);
|
|
2816
|
+
const orphans = findOrphanScreens(screens);
|
|
2817
|
+
if (orphans.length > 0) {
|
|
2818
|
+
hasWarnings = true;
|
|
2819
|
+
logger.blank();
|
|
2820
|
+
logger.warn(`Orphan screens detected (${orphans.length}):`);
|
|
2821
|
+
logger.blank();
|
|
2822
|
+
logger.log(" These screens have no entryPoints and are not");
|
|
2823
|
+
logger.log(" referenced in any other screen's 'next' array.");
|
|
2824
|
+
logger.blank();
|
|
2825
|
+
for (const orphan of orphans) logger.itemWarn(`${orphan.id} ${logger.dim(orphan.route)}`);
|
|
2826
|
+
logger.blank();
|
|
2827
|
+
logger.log(` ${logger.dim("Consider adding entryPoints or removing these screens.")}`);
|
|
2828
|
+
}
|
|
2829
|
+
if (!allowCycles) {
|
|
2830
|
+
const cycleResult = detectCycles(screens);
|
|
2831
|
+
if (cycleResult.hasCycles) {
|
|
2832
|
+
hasWarnings = true;
|
|
2833
|
+
logger.blank();
|
|
2834
|
+
logger.warn(getCycleSummary(cycleResult));
|
|
2835
|
+
logger.log(formatCycleWarnings(cycleResult.cycles));
|
|
2836
|
+
logger.blank();
|
|
2837
|
+
if (cycleResult.disallowedCycles.length > 0) {
|
|
2838
|
+
logger.log(` ${logger.dim("Use 'allowCycles: true' in screen.meta.ts to allow intentional cycles.")}`);
|
|
2839
|
+
if (strict) {
|
|
2840
|
+
logger.blank();
|
|
2841
|
+
logger.errorWithHelp(ERRORS.CYCLES_DETECTED(cycleResult.disallowedCycles.length));
|
|
2842
|
+
process.exit(1);
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
const invalidNavs = findInvalidNavigations(screens);
|
|
2848
|
+
if (invalidNavs.length > 0) {
|
|
2849
|
+
hasWarnings = true;
|
|
2850
|
+
logger.blank();
|
|
2851
|
+
logger.warn(`Invalid navigation targets (${invalidNavs.length}):`);
|
|
2852
|
+
logger.blank();
|
|
2853
|
+
logger.log(" These navigation references point to non-existent screens.");
|
|
2854
|
+
logger.blank();
|
|
2855
|
+
for (const inv of invalidNavs) logger.itemWarn(`${inv.screenId} → ${logger.dim(inv.field)}: "${inv.target}"`);
|
|
2856
|
+
logger.blank();
|
|
2857
|
+
logger.log(` ${logger.dim("Check that these screen IDs exist in your codebase.")}`);
|
|
2858
|
+
}
|
|
2859
|
+
} catch (error) {
|
|
2860
|
+
if (error instanceof SyntaxError) {
|
|
2861
|
+
logger.warn("Failed to parse screens.json - file may be corrupted");
|
|
2862
|
+
logger.log(` ${logger.dim("Run 'screenbook build' to regenerate.")}`);
|
|
2863
|
+
hasWarnings = true;
|
|
2864
|
+
} else if (error instanceof Error) {
|
|
2865
|
+
logger.warn(`Failed to analyze screens.json: ${error.message}`);
|
|
2866
|
+
hasWarnings = true;
|
|
2867
|
+
} else {
|
|
2868
|
+
logger.warn(`Failed to analyze screens.json: ${String(error)}`);
|
|
2869
|
+
hasWarnings = true;
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
if (hasWarnings) {
|
|
2873
|
+
logger.blank();
|
|
2874
|
+
logger.warn("Lint completed with warnings.");
|
|
2875
|
+
}
|
|
2876
|
+
return hasWarnings;
|
|
2877
|
+
}
|
|
2878
|
+
/**
|
|
2879
|
+
* Determine the directory where screen.meta.ts should be for a route
|
|
2880
|
+
*/
|
|
2881
|
+
function determineMetaDir(route, cwd) {
|
|
2882
|
+
if (route.componentPath) {
|
|
2883
|
+
const relativePath = relative(cwd, dirname(route.componentPath));
|
|
2884
|
+
if (!relativePath.startsWith("..")) return relativePath;
|
|
2885
|
+
}
|
|
2886
|
+
return join("src", "screens", route.screenId.replace(/\./g, "/"));
|
|
2887
|
+
}
|
|
2888
|
+
/**
|
|
2889
|
+
* Determine the suggested screen.meta.ts path for a route
|
|
2890
|
+
*/
|
|
2891
|
+
function determineSuggestedMetaPath(route, cwd) {
|
|
2892
|
+
return join(determineMetaDir(route, cwd), "screen.meta.ts");
|
|
2893
|
+
}
|
|
2894
|
+
/**
|
|
1821
2895
|
* Find navigation references that point to non-existent screens.
|
|
1822
2896
|
* Checks `next`, `entryPoints` arrays and mock navigation targets.
|
|
1823
2897
|
*/
|