apex-auditor 0.1.1 → 0.1.3

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.
@@ -1,10 +1,10 @@
1
1
  import lighthouse from "lighthouse";
2
- import chromeLauncher from "chrome-launcher";
2
+ import { launch as launchChrome } from "chrome-launcher";
3
3
  async function createChromeSession(chromePort) {
4
4
  if (typeof chromePort === "number") {
5
5
  return { port: chromePort };
6
6
  }
7
- const chrome = await chromeLauncher.launch({
7
+ const chrome = await launchChrome({
8
8
  chromeFlags: [
9
9
  "--headless=new",
10
10
  "--disable-gpu",
@@ -0,0 +1,92 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { pathExists } from "./fs-utils.js";
4
+ const DEFAULT_MAX_DEPTH = 3;
5
+ const IGNORED_DIRECTORIES = [
6
+ "node_modules",
7
+ ".git",
8
+ ".next",
9
+ ".turbo",
10
+ ".vercel",
11
+ "dist",
12
+ "build",
13
+ "out",
14
+ "coverage",
15
+ ".cache",
16
+ ];
17
+ /**
18
+ * Discover Next.js projects below the given repository root.
19
+ * This performs a shallow breadth-first search and looks for either
20
+ * a next.config.* file or a package.json that depends on "next".
21
+ */
22
+ export async function discoverNextProjects(options) {
23
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
24
+ const queue = [{ path: options.repoRoot, depth: 0 }];
25
+ const visited = new Set();
26
+ const projects = [];
27
+ while (queue.length > 0) {
28
+ const current = queue.shift();
29
+ if (visited.has(current.path)) {
30
+ continue;
31
+ }
32
+ visited.add(current.path);
33
+ if (await isNextProjectRoot(current.path)) {
34
+ const name = basename(current.path) || ".";
35
+ projects.push({ name, root: current.path, framework: "next" });
36
+ continue;
37
+ }
38
+ if (current.depth >= maxDepth) {
39
+ continue;
40
+ }
41
+ let entries;
42
+ try {
43
+ entries = await readdir(current.path, { withFileTypes: true });
44
+ }
45
+ catch {
46
+ continue;
47
+ }
48
+ for (const entry of entries) {
49
+ if (!entry.isDirectory()) {
50
+ continue;
51
+ }
52
+ if (IGNORED_DIRECTORIES.includes(entry.name)) {
53
+ continue;
54
+ }
55
+ const childPath = join(current.path, entry.name);
56
+ queue.push({ path: childPath, depth: current.depth + 1 });
57
+ }
58
+ }
59
+ return projects;
60
+ }
61
+ async function isNextProjectRoot(directory) {
62
+ const nextConfigCandidates = [
63
+ "next.config.js",
64
+ "next.config.mjs",
65
+ "next.config.cjs",
66
+ "next.config.ts",
67
+ ];
68
+ for (const candidate of nextConfigCandidates) {
69
+ const configPath = join(directory, candidate);
70
+ if (await pathExists(configPath)) {
71
+ return true;
72
+ }
73
+ }
74
+ const packageJsonPath = join(directory, "package.json");
75
+ if (!(await pathExists(packageJsonPath))) {
76
+ return false;
77
+ }
78
+ try {
79
+ const raw = await readFile(packageJsonPath, "utf8");
80
+ const parsed = JSON.parse(raw);
81
+ if (!parsed || typeof parsed !== "object") {
82
+ return false;
83
+ }
84
+ const maybePackage = parsed;
85
+ const hasNextDependency = Boolean((maybePackage.dependencies && typeof maybePackage.dependencies.next === "string") ||
86
+ (maybePackage.devDependencies && typeof maybePackage.devDependencies.next === "string"));
87
+ return hasNextDependency;
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
@@ -42,15 +42,43 @@ export async function detectRoutes(options) {
42
42
  function createNextAppDetector() {
43
43
  return {
44
44
  id: SOURCE_NEXT_APP,
45
- canDetect: async (options) => pathExists(join(options.projectRoot, "app")),
46
- detect: async (options) => detectAppRoutes(join(options.projectRoot, "app"), options.limit),
45
+ canDetect: async (options) => {
46
+ const roots = await findNextAppRoots(options.projectRoot);
47
+ return roots.length > 0;
48
+ },
49
+ detect: async (options) => {
50
+ const roots = await findNextAppRoots(options.projectRoot);
51
+ const allRoutes = [];
52
+ for (const root of roots) {
53
+ const routes = await detectAppRoutes(root, options.limit);
54
+ allRoutes.push(...routes);
55
+ if (allRoutes.length >= options.limit) {
56
+ break;
57
+ }
58
+ }
59
+ return allRoutes;
60
+ },
47
61
  };
48
62
  }
49
63
  function createNextPagesDetector() {
50
64
  return {
51
65
  id: SOURCE_NEXT_PAGES,
52
- canDetect: async (options) => pathExists(join(options.projectRoot, "pages")),
53
- detect: async (options) => detectPagesRoutes(join(options.projectRoot, "pages"), options.limit),
66
+ canDetect: async (options) => {
67
+ const roots = await findNextPagesRoots(options.projectRoot);
68
+ return roots.length > 0;
69
+ },
70
+ detect: async (options) => {
71
+ const roots = await findNextPagesRoots(options.projectRoot);
72
+ const allRoutes = [];
73
+ for (const root of roots) {
74
+ const routes = await detectPagesRoutes(root, options.limit);
75
+ allRoutes.push(...routes);
76
+ if (allRoutes.length >= options.limit) {
77
+ break;
78
+ }
79
+ }
80
+ return allRoutes;
81
+ },
54
82
  };
55
83
  }
56
84
  function createRemixRoutesDetector() {
@@ -67,6 +95,90 @@ function createSpaHtmlDetector() {
67
95
  detect: async (options) => detectSpaRoutes(options.projectRoot, options.limit),
68
96
  };
69
97
  }
98
+ async function findNextAppRoots(projectRoot) {
99
+ const roots = [];
100
+ const seen = new Set();
101
+ const addRoot = (candidate) => {
102
+ if (!seen.has(candidate)) {
103
+ seen.add(candidate);
104
+ roots.push(candidate);
105
+ }
106
+ };
107
+ const directCandidates = [
108
+ join(projectRoot, "app"),
109
+ join(projectRoot, "src", "app"),
110
+ ];
111
+ for (const candidate of directCandidates) {
112
+ if (await pathExists(candidate)) {
113
+ addRoot(candidate);
114
+ }
115
+ }
116
+ const containers = ["apps", "packages"];
117
+ for (const container of containers) {
118
+ const containerPath = join(projectRoot, container);
119
+ if (!(await pathExists(containerPath))) {
120
+ continue;
121
+ }
122
+ const entries = await readdir(containerPath, { withFileTypes: true });
123
+ for (const entry of entries) {
124
+ if (!entry.isDirectory()) {
125
+ continue;
126
+ }
127
+ const appRoot = join(containerPath, entry.name, "app");
128
+ const srcAppRoot = join(containerPath, entry.name, "src", "app");
129
+ if (await pathExists(appRoot)) {
130
+ addRoot(appRoot);
131
+ continue;
132
+ }
133
+ if (await pathExists(srcAppRoot)) {
134
+ addRoot(srcAppRoot);
135
+ }
136
+ }
137
+ }
138
+ return roots;
139
+ }
140
+ async function findNextPagesRoots(projectRoot) {
141
+ const roots = [];
142
+ const seen = new Set();
143
+ const addRoot = (candidate) => {
144
+ if (!seen.has(candidate)) {
145
+ seen.add(candidate);
146
+ roots.push(candidate);
147
+ }
148
+ };
149
+ const directCandidates = [
150
+ join(projectRoot, "pages"),
151
+ join(projectRoot, "src", "pages"),
152
+ ];
153
+ for (const candidate of directCandidates) {
154
+ if (await pathExists(candidate)) {
155
+ addRoot(candidate);
156
+ }
157
+ }
158
+ const containers = ["apps", "packages"];
159
+ for (const container of containers) {
160
+ const containerPath = join(projectRoot, container);
161
+ if (!(await pathExists(containerPath))) {
162
+ continue;
163
+ }
164
+ const entries = await readdir(containerPath, { withFileTypes: true });
165
+ for (const entry of entries) {
166
+ if (!entry.isDirectory()) {
167
+ continue;
168
+ }
169
+ const pagesRoot = join(containerPath, entry.name, "pages");
170
+ const srcPagesRoot = join(containerPath, entry.name, "src", "pages");
171
+ if (await pathExists(pagesRoot)) {
172
+ addRoot(pagesRoot);
173
+ continue;
174
+ }
175
+ if (await pathExists(srcPagesRoot)) {
176
+ addRoot(srcPagesRoot);
177
+ }
178
+ }
179
+ }
180
+ return roots;
181
+ }
70
182
  async function detectAppRoutes(appRoot, limit) {
71
183
  const files = await collectRouteFiles(appRoot, limit, isAppPageFile);
72
184
  return files.map((file) => buildRoute(file, appRoot, formatAppRoutePath, SOURCE_NEXT_APP));
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
3
3
  import prompts from "prompts";
4
4
  import { detectRoutes } from "./route-detectors.js";
5
5
  import { pathExists } from "./fs-utils.js";
6
+ import { discoverNextProjects } from "./project-discovery.js";
6
7
  const PROFILE_TO_DETECTOR = {
7
8
  "next-app": "next-app",
8
9
  "next-pages": "next-pages",
@@ -194,18 +195,44 @@ async function maybeDetectPages(profile) {
194
195
  }
195
196
  const preferredDetector = await selectDetector(profile);
196
197
  const projectRootAnswer = await ask(projectRootQuestion);
197
- const absoluteRoot = resolve(projectRootAnswer.projectRoot);
198
- if (!(await pathExists(absoluteRoot))) {
199
- console.log(`No project found at ${absoluteRoot}. Skipping auto-detection.`);
198
+ const repoRoot = resolve(projectRootAnswer.projectRoot);
199
+ if (!(await pathExists(repoRoot))) {
200
+ console.log(`No project found at ${repoRoot}. Skipping auto-detection.`);
200
201
  return [];
201
202
  }
202
- const routes = await detectRoutes({ projectRoot: absoluteRoot, preferredDetectorId: preferredDetector });
203
+ const detectionRoot = await chooseDetectionRoot({ profile, repoRoot });
204
+ const routes = await detectRoutes({ projectRoot: detectionRoot, preferredDetectorId: preferredDetector });
203
205
  if (routes.length === 0) {
204
206
  console.log("No routes detected. Add pages manually.");
205
207
  return [];
206
208
  }
207
209
  return selectDetectedRoutes(routes);
208
210
  }
211
+ async function chooseDetectionRoot({ profile, repoRoot, }) {
212
+ if (profile !== "next-app" && profile !== "next-pages") {
213
+ return repoRoot;
214
+ }
215
+ const projects = await discoverNextProjects({ repoRoot });
216
+ if (projects.length === 0) {
217
+ return repoRoot;
218
+ }
219
+ if (projects.length === 1) {
220
+ const onlyProject = projects[0];
221
+ console.log(`Detected Next.js app at ${onlyProject.root}.`);
222
+ return onlyProject.root;
223
+ }
224
+ const choices = projects.map((project) => ({
225
+ title: `${project.name} (${project.root})`,
226
+ value: project.root,
227
+ }));
228
+ const answer = await ask({
229
+ type: "select",
230
+ name: "projectRoot",
231
+ message: "Multiple Next.js apps found. Which one do you want to audit?",
232
+ choices,
233
+ });
234
+ return answer.projectRoot ?? repoRoot;
235
+ }
209
236
  async function selectDetector(profile) {
210
237
  const preset = PROFILE_TO_DETECTOR[profile];
211
238
  if (preset) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apex-auditor",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "description": "CLI to run structured Lighthouse audits (Performance, Accessibility, Best Practices, SEO) across routes.",
6
6
  "type": "module",
@@ -18,8 +18,8 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "chrome-launcher": "^0.15.2",
22
- "lighthouse": "^12.6.1",
21
+ "chrome-launcher": "^1.2.1",
22
+ "lighthouse": "^13.0.1",
23
23
  "prompts": "^2.4.2"
24
24
  },
25
25
  "devDependencies": {
@@ -27,6 +27,6 @@
27
27
  "typescript": "^5.9.3",
28
28
  "@types/node": "^22.0.0",
29
29
  "@types/prompts": "^2.4.9",
30
- "vitest": "^1.3.1"
30
+ "vitest": "^4.0.14"
31
31
  }
32
32
  }