Skip to content

Commit 9b47b55

Browse files
authored
Implement project root detection and enhance route path resolution in CLI (#21)
- Added a new `findProjectRoot` function to locate the project root directory by searching for `package.json` in parent directories. - Updated `resolveRoutePath` methods in `NextAppRouterGenerator` and `NextPagesRouterGenerator` to utilize the detected project root for more accurate route path resolution. - Enhanced tests for route path resolution to cover various scenarios, including multiple `/app/` segments and projects using a `src` directory structure. - Introduced a new test file for `findProjectRoot` to ensure its functionality and correctness. These changes improve the flexibility and accuracy of route path generation in the CLI, particularly for complex project structures. Co-authored-by: Nico Prananta <311343+nicnocquee@users.noreply.github.com>
1 parent b77848a commit 9b47b55

7 files changed

Lines changed: 199 additions & 12 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect, vi, afterEach } from "vitest";
2+
import { findProjectRoot } from "./find-project-root.js";
3+
import path from "node:path";
4+
5+
describe("findProjectRoot", () => {
6+
afterEach(() => {
7+
vi.restoreAllMocks();
8+
});
9+
10+
it("returns the directory that contains package.json when walking up", () => {
11+
// Setup: only /project has package.json
12+
const projectRoot = path.resolve("/project");
13+
const configDir = path.join(projectRoot, "app", "dashboard", "tickers");
14+
const existsSync = vi.fn((filePath: string) => {
15+
return filePath === path.join(projectRoot, "package.json");
16+
});
17+
18+
// Act
19+
const result = findProjectRoot(configDir, existsSync);
20+
21+
// Assert
22+
expect(result).toBe(projectRoot);
23+
expect(existsSync).toHaveBeenCalledWith(
24+
path.join(projectRoot, "package.json"),
25+
);
26+
});
27+
28+
it("returns the first ancestor with package.json when multiple exist", () => {
29+
// Setup: both /project and /project/app have package.json; we want the nearest (first found when walking up)
30+
const projectRoot = path.resolve("/project");
31+
const appDir = path.join(projectRoot, "app");
32+
const configDir = path.join(appDir, "dashboard");
33+
const existsSync = vi.fn((filePath: string) => {
34+
return (
35+
filePath === path.join(projectRoot, "package.json") ||
36+
filePath === path.join(appDir, "package.json")
37+
);
38+
});
39+
40+
// Act
41+
const result = findProjectRoot(configDir, existsSync);
42+
43+
// Assert: should find app's package.json first (nearest when walking up)
44+
expect(result).toBe(appDir);
45+
});
46+
47+
it("returns null when no ancestor has package.json", () => {
48+
// Setup: no directory has package.json
49+
const configDir = path.resolve("/some", "deep", "directory");
50+
const existsSync = vi.fn(() => false);
51+
52+
// Act
53+
const result = findProjectRoot(configDir, existsSync);
54+
55+
// Assert
56+
expect(result).toBeNull();
57+
});
58+
59+
it("returns the given directory when it contains package.json", () => {
60+
// Setup: the directory itself has package.json
61+
const projectRoot = path.resolve("/project");
62+
const existsSync = vi.fn((filePath: string) => {
63+
return filePath === path.join(projectRoot, "package.json");
64+
});
65+
66+
// Act
67+
const result = findProjectRoot(projectRoot, existsSync);
68+
69+
// Assert
70+
expect(result).toBe(projectRoot);
71+
});
72+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import path from "node:path";
2+
3+
/**
4+
* Finds the project root by walking up from the given directory until a
5+
* directory containing package.json is found.
6+
*
7+
* @param directory - Absolute path to start from (e.g. config directory).
8+
* @param existsSync - Function to check if a file path exists (e.g. fs.existsSync).
9+
* @returns The absolute path of the directory containing package.json, or null if not found.
10+
*/
11+
export function findProjectRoot(
12+
directory: string,
13+
existsSync: (filePath: string) => boolean,
14+
): string | null {
15+
let current = path.resolve(directory);
16+
const root = path.parse(current).root;
17+
18+
while (current !== root) {
19+
const packagePath = path.join(current, "package.json");
20+
if (existsSync(packagePath)) {
21+
return current;
22+
}
23+
const parent = path.dirname(current);
24+
if (parent === current) {
25+
break;
26+
}
27+
current = parent;
28+
}
29+
30+
return null;
31+
}

packages/route-action-gen/src/cli/frameworks/next-app-router.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,30 @@ export class NextAppRouterGenerator implements FrameworkGenerator {
5959
};
6060
}
6161

62-
resolveRoutePath(directory: string): string {
63-
// Find the 'app/' segment in the path and use everything after it
64-
const appIndex = directory.indexOf("/app/");
62+
resolveRoutePath(directory: string, projectRoot?: string | null): string {
63+
if (projectRoot) {
64+
const normalizedDir = path.resolve(directory);
65+
// Next.js supports both app/ and src/app/ at project root
66+
const appDirCandidates = [
67+
path.join(projectRoot, "app"),
68+
path.join(projectRoot, "src", "app"),
69+
];
70+
for (const appDir of appDirCandidates) {
71+
const normalizedAppDir = path.resolve(appDir);
72+
if (
73+
normalizedDir === normalizedAppDir ||
74+
normalizedDir.startsWith(normalizedAppDir + path.sep)
75+
) {
76+
const relative = path.relative(normalizedAppDir, normalizedDir);
77+
return "/" + relative.split(path.sep).join("/");
78+
}
79+
}
80+
}
81+
// Fallback: use the last 'app/' segment so Docker WORKDIR /app or parent /app/ still work
82+
const appIndex = directory.lastIndexOf("/app/");
6583
if (appIndex !== -1) {
6684
return directory.slice(appIndex + "/app".length);
6785
}
68-
// Fallback: use the last path segment
6986
const parts = directory.split("/");
7087
return "/" + parts[parts.length - 1];
7188
}

packages/route-action-gen/src/cli/frameworks/next-pages-router.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,30 @@ export class NextPagesRouterGenerator implements FrameworkGenerator {
6060
};
6161
}
6262

63-
resolveRoutePath(directory: string): string {
64-
// Find the 'pages/' segment in the path and use everything after it
65-
const pagesIndex = directory.indexOf("/pages/");
63+
resolveRoutePath(directory: string, projectRoot?: string | null): string {
64+
if (projectRoot) {
65+
const normalizedDir = path.resolve(directory);
66+
// Next.js supports both pages/ and src/pages/ at project root
67+
const pagesDirCandidates = [
68+
path.join(projectRoot, "pages"),
69+
path.join(projectRoot, "src", "pages"),
70+
];
71+
for (const pagesDir of pagesDirCandidates) {
72+
const normalizedPagesDir = path.resolve(pagesDir);
73+
if (
74+
normalizedDir === normalizedPagesDir ||
75+
normalizedDir.startsWith(normalizedPagesDir + path.sep)
76+
) {
77+
const relative = path.relative(normalizedPagesDir, normalizedDir);
78+
return "/" + relative.split(path.sep).join("/");
79+
}
80+
}
81+
}
82+
// Fallback: use the last 'pages/' segment for consistency with app router
83+
const pagesIndex = directory.lastIndexOf("/pages/");
6684
if (pagesIndex !== -1) {
6785
return directory.slice(pagesIndex + "/pages".length);
6886
}
69-
// Fallback: use the last path segment
7087
const parts = directory.split("/");
7188
return "/" + parts[parts.length - 1];
7289
}

packages/route-action-gen/src/cli/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from "./frameworks/index.js";
2626
import type { CliDeps, GenerationContext, HttpMethod } from "./types.js";
2727
import { createConfigFile, isValidMethod } from "./create.js";
28+
import { findProjectRoot } from "./find-project-root.js";
2829

2930
// Re-export modules for testing
3031
export { scanConfigFiles } from "./scanner.js";
@@ -252,8 +253,9 @@ export function generate(
252253
return parseConfigFile(content, scanned.method, scanned.fileName);
253254
});
254255

255-
// Compute the route path using the framework generator
256-
const routePath = generator.resolveRoutePath(group.directory);
256+
// Compute the route path using the framework generator (project root from package.json when available)
257+
const projectRoot = findProjectRoot(group.directory, deps.existsSync);
258+
const routePath = generator.resolveRoutePath(group.directory, projectRoot);
257259

258260
// Resolve the generated output directory (may differ from config dir)
259261
const generatedDir = generator.resolveGeneratedDir(group.directory, cwd);

packages/route-action-gen/src/cli/types.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,14 @@ export interface FrameworkGenerator {
8787
name: string;
8888
/**
8989
* Compute the route path from a config file's absolute directory path.
90-
* e.g. "/abs/path/app/api/posts/[postId]" -> "/api/posts/[postId]"
90+
* When projectRoot is provided (e.g. from findProjectRoot), the route path
91+
* is relative to projectRoot/app (or projectRoot/pages). Otherwise falls back
92+
* to path heuristics. e.g. "/abs/path/app/api/posts/[postId]" -> "/api/posts/[postId]"
93+
*
94+
* @param directory - Absolute path to the config directory.
95+
* @param projectRoot - Optional directory containing package.json; when set, route path is derived from it.
9196
*/
92-
resolveRoutePath(directory: string): string;
97+
resolveRoutePath(directory: string, projectRoot?: string | null): string;
9398
/**
9499
* Resolve the absolute path of the generated output directory.
95100
*

packages/route-action-gen/src/index.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,38 @@ describe("NextAppRouterGenerator", () => {
446446
generator.resolveRoutePath("/project/apps/web/app/api/users"),
447447
).toBe("/api/users");
448448
});
449+
450+
it("uses projectRoot when path contains /app/ more than once (Docker or monorepo)", () => {
451+
// Act
452+
const result = generator.resolveRoutePath(
453+
"/app/apps/hermes/app/dashboard/tickers/actions/import",
454+
"/app/apps/hermes",
455+
);
456+
457+
// Assert: route path is relative to projectRoot/app, not the first /app/
458+
expect(result).toBe("/dashboard/tickers/actions/import");
459+
});
460+
461+
it("falls back to lastIndexOf when projectRoot is not provided", () => {
462+
// Act: same path as above but no projectRoot
463+
const result = generator.resolveRoutePath(
464+
"/app/apps/hermes/app/dashboard/tickers/actions/import",
465+
);
466+
467+
// Assert: fallback uses last /app/ so we still get the Next.js route path
468+
expect(result).toBe("/dashboard/tickers/actions/import");
469+
});
470+
471+
it("uses projectRoot with src/app when Next.js project uses src directory", () => {
472+
// Act
473+
const result = generator.resolveRoutePath(
474+
"/project/src/app/api/users",
475+
"/project",
476+
);
477+
478+
// Assert: route path is relative to projectRoot/src/app
479+
expect(result).toBe("/api/users");
480+
});
449481
});
450482

451483
describe("generate", () => {
@@ -705,6 +737,17 @@ describe("NextPagesRouterGenerator", () => {
705737
"/api/users",
706738
);
707739
});
740+
741+
it("uses projectRoot with src/pages when Next.js project uses src directory", () => {
742+
// Act
743+
const result = generator.resolveRoutePath(
744+
"/project/src/pages/api/users",
745+
"/project",
746+
);
747+
748+
// Assert: route path is relative to projectRoot/src/pages
749+
expect(result).toBe("/api/users");
750+
});
708751
});
709752

710753
describe("generate", () => {

0 commit comments

Comments
 (0)