@screenbook/cli 1.1.3 → 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 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: "routesPattern not configured",
170
- suggestion: "Add routesPattern to your screenbook.config.ts to specify where your route files are located.",
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", // Adjust for your framework
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.",
@@ -943,6 +964,721 @@ function displayResults(results, verbose) {
943
964
  }
944
965
  }
945
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
+
946
1682
  //#endregion
947
1683
  //#region src/commands/generate.ts
948
1684
  const generateCommand = define({
@@ -979,92 +1715,229 @@ const generateCommand = define({
979
1715
  const dryRun = ctx.values.dryRun ?? false;
980
1716
  const force = ctx.values.force ?? false;
981
1717
  const interactive = ctx.values.interactive ?? false;
982
- if (!config.routesPattern) {
1718
+ if (!config.routesPattern && !config.routesFile) {
983
1719
  logger.errorWithHelp(ERRORS.ROUTES_PATTERN_MISSING);
984
1720
  process.exit(1);
985
1721
  }
986
- logger.info("Scanning for route files...");
987
- logger.blank();
988
- const routeFiles = await glob(config.routesPattern, {
989
- cwd,
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,
990
1734
  ignore: config.ignore
991
1735
  });
992
- if (routeFiles.length === 0) {
993
- logger.warn(`No route files found matching: ${config.routesPattern}`);
994
- return;
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;
995
1783
  }
996
- logger.log(`Found ${routeFiles.length} route files`);
997
- logger.blank();
998
- let created = 0;
999
- let skipped = 0;
1000
- for (const routeFile of routeFiles) {
1001
- const routeDir = dirname(routeFile);
1002
- const metaPath = join(routeDir, "screen.meta.ts");
1003
- const absoluteMetaPath = join(cwd, metaPath);
1004
- if (!force && existsSync(absoluteMetaPath)) {
1005
- if (!interactive) {
1006
- skipped++;
1007
- continue;
1008
- }
1009
- 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)}`);
1010
1793
  skipped++;
1011
1794
  continue;
1012
1795
  }
1013
- const screenMeta = inferScreenMeta(routeDir, config.routesPattern);
1014
- if (interactive) {
1015
- const result = await promptForScreen(routeFile, screenMeta);
1016
- if (result.skip) {
1017
- logger.itemWarn(`Skipped: ${logger.path(metaPath)}`);
1018
- skipped++;
1019
- continue;
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)}`);
1048
- }
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;
1049
1841
  }
1050
- created++;
1842
+ logger.itemWarn(`Exists: ${logger.path(metaPath)} (use --force to overwrite)`);
1843
+ skipped++;
1844
+ continue;
1051
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;
1853
+ }
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++;
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)`);
1052
1929
  logger.blank();
1053
- if (dryRun) {
1054
- logger.info(`Would create ${created} files (${skipped} already exist)`);
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) {
1055
1934
  logger.blank();
1056
- logger.log(`Run without ${logger.code("--dry-run")} to create files`);
1057
- } else {
1058
- logger.done(`Created ${created} files (${skipped} skipped)`);
1059
- if (created > 0) {
1060
- logger.blank();
1061
- logger.log(logger.bold("Next steps:"));
1062
- logger.log(" 1. Review and customize the generated screen.meta.ts files");
1063
- logger.log(` 2. Run ${logger.code("screenbook dev")} to view your screen catalog`);
1064
- }
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`);
1065
1938
  }
1066
1939
  }
1067
- });
1940
+ }
1068
1941
  /**
1069
1942
  * Parse comma-separated string into array
1070
1943
  */
@@ -1706,7 +2579,7 @@ const lintCommand = define({
1706
2579
  const cwd = process.cwd();
1707
2580
  const adoption = config.adoption ?? { mode: "full" };
1708
2581
  let hasWarnings = false;
1709
- if (!config.routesPattern) {
2582
+ if (!config.routesPattern && !config.routesFile) {
1710
2583
  logger.errorWithHelp(ERRORS.ROUTES_PATTERN_MISSING);
1711
2584
  process.exit(1);
1712
2585
  }
@@ -1717,6 +2590,10 @@ const lintCommand = define({
1717
2590
  if (adoption.minimumCoverage != null) logger.log(`Minimum coverage: ${adoption.minimumCoverage}%`);
1718
2591
  }
1719
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
+ }
1720
2597
  let routeFiles = await glob(config.routesPattern, {
1721
2598
  cwd,
1722
2599
  ignore: config.ignore
@@ -1821,6 +2698,9 @@ const lintCommand = define({
1821
2698
  } else if (error instanceof Error) {
1822
2699
  logger.warn(`Failed to analyze screens.json: ${error.message}`);
1823
2700
  hasWarnings = true;
2701
+ } else {
2702
+ logger.warn(`Failed to analyze screens.json: ${String(error)}`);
2703
+ hasWarnings = true;
1824
2704
  }
1825
2705
  }
1826
2706
  if (hasWarnings) {
@@ -1830,6 +2710,188 @@ const lintCommand = define({
1830
2710
  }
1831
2711
  });
1832
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
+ /**
1833
2895
  * Find navigation references that point to non-existent screens.
1834
2896
  * Checks `next`, `entryPoints` arrays and mock navigation targets.
1835
2897
  */