@screenbook/cli 1.1.3 → 1.3.0

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