Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions packages/route-action-gen/src/cli/find-project-root.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { findProjectRoot } from "./find-project-root.js";
import path from "node:path";

describe("findProjectRoot", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("returns the directory that contains package.json when walking up", () => {
// Setup: only /project has package.json
const projectRoot = path.resolve("/project");
const configDir = path.join(projectRoot, "app", "dashboard", "tickers");
const existsSync = vi.fn((filePath: string) => {
return filePath === path.join(projectRoot, "package.json");
});

// Act
const result = findProjectRoot(configDir, existsSync);

// Assert
expect(result).toBe(projectRoot);
expect(existsSync).toHaveBeenCalledWith(
path.join(projectRoot, "package.json"),
);
});

it("returns the first ancestor with package.json when multiple exist", () => {
// Setup: both /project and /project/app have package.json; we want the nearest (first found when walking up)
const projectRoot = path.resolve("/project");
const appDir = path.join(projectRoot, "app");
const configDir = path.join(appDir, "dashboard");
const existsSync = vi.fn((filePath: string) => {
return (
filePath === path.join(projectRoot, "package.json") ||
filePath === path.join(appDir, "package.json")
);
});

// Act
const result = findProjectRoot(configDir, existsSync);

// Assert: should find app's package.json first (nearest when walking up)
expect(result).toBe(appDir);
});

it("returns null when no ancestor has package.json", () => {
// Setup: no directory has package.json
const configDir = path.resolve("/some", "deep", "directory");
const existsSync = vi.fn(() => false);

// Act
const result = findProjectRoot(configDir, existsSync);

// Assert
expect(result).toBeNull();
});

it("returns the given directory when it contains package.json", () => {
// Setup: the directory itself has package.json
const projectRoot = path.resolve("/project");
const existsSync = vi.fn((filePath: string) => {
return filePath === path.join(projectRoot, "package.json");
});

// Act
const result = findProjectRoot(projectRoot, existsSync);

// Assert
expect(result).toBe(projectRoot);
});
});
31 changes: 31 additions & 0 deletions packages/route-action-gen/src/cli/find-project-root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import path from "node:path";

/**
* Finds the project root by walking up from the given directory until a
* directory containing package.json is found.
*
* @param directory - Absolute path to start from (e.g. config directory).
* @param existsSync - Function to check if a file path exists (e.g. fs.existsSync).
* @returns The absolute path of the directory containing package.json, or null if not found.
*/
export function findProjectRoot(
directory: string,
existsSync: (filePath: string) => boolean,
): string | null {
let current = path.resolve(directory);
const root = path.parse(current).root;

while (current !== root) {
const packagePath = path.join(current, "package.json");
if (existsSync(packagePath)) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}

return null;
}
25 changes: 21 additions & 4 deletions packages/route-action-gen/src/cli/frameworks/next-app-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,30 @@ export class NextAppRouterGenerator implements FrameworkGenerator {
};
}

resolveRoutePath(directory: string): string {
// Find the 'app/' segment in the path and use everything after it
const appIndex = directory.indexOf("/app/");
resolveRoutePath(directory: string, projectRoot?: string | null): string {
if (projectRoot) {
const normalizedDir = path.resolve(directory);
// Next.js supports both app/ and src/app/ at project root
const appDirCandidates = [
path.join(projectRoot, "app"),
path.join(projectRoot, "src", "app"),
];
for (const appDir of appDirCandidates) {
const normalizedAppDir = path.resolve(appDir);
if (
normalizedDir === normalizedAppDir ||
normalizedDir.startsWith(normalizedAppDir + path.sep)
) {
const relative = path.relative(normalizedAppDir, normalizedDir);
return "/" + relative.split(path.sep).join("/");
}
}
}
// Fallback: use the last 'app/' segment so Docker WORKDIR /app or parent /app/ still work
const appIndex = directory.lastIndexOf("/app/");
if (appIndex !== -1) {
return directory.slice(appIndex + "/app".length);
}
// Fallback: use the last path segment
const parts = directory.split("/");
return "/" + parts[parts.length - 1];
}
Expand Down
25 changes: 21 additions & 4 deletions packages/route-action-gen/src/cli/frameworks/next-pages-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,30 @@ export class NextPagesRouterGenerator implements FrameworkGenerator {
};
}

resolveRoutePath(directory: string): string {
// Find the 'pages/' segment in the path and use everything after it
const pagesIndex = directory.indexOf("/pages/");
resolveRoutePath(directory: string, projectRoot?: string | null): string {
if (projectRoot) {
const normalizedDir = path.resolve(directory);
// Next.js supports both pages/ and src/pages/ at project root
const pagesDirCandidates = [
path.join(projectRoot, "pages"),
path.join(projectRoot, "src", "pages"),
];
for (const pagesDir of pagesDirCandidates) {
const normalizedPagesDir = path.resolve(pagesDir);
if (
normalizedDir === normalizedPagesDir ||
normalizedDir.startsWith(normalizedPagesDir + path.sep)
) {
const relative = path.relative(normalizedPagesDir, normalizedDir);
return "/" + relative.split(path.sep).join("/");
}
}
}
// Fallback: use the last 'pages/' segment for consistency with app router
const pagesIndex = directory.lastIndexOf("/pages/");
if (pagesIndex !== -1) {
return directory.slice(pagesIndex + "/pages".length);
}
// Fallback: use the last path segment
const parts = directory.split("/");
return "/" + parts[parts.length - 1];
}
Expand Down
6 changes: 4 additions & 2 deletions packages/route-action-gen/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from "./frameworks/index.js";
import type { CliDeps, GenerationContext, HttpMethod } from "./types.js";
import { createConfigFile, isValidMethod } from "./create.js";
import { findProjectRoot } from "./find-project-root.js";

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

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

// Resolve the generated output directory (may differ from config dir)
const generatedDir = generator.resolveGeneratedDir(group.directory, cwd);
Expand Down
9 changes: 7 additions & 2 deletions packages/route-action-gen/src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,14 @@ export interface FrameworkGenerator {
name: string;
/**
* Compute the route path from a config file's absolute directory path.
* e.g. "/abs/path/app/api/posts/[postId]" -> "/api/posts/[postId]"
* When projectRoot is provided (e.g. from findProjectRoot), the route path
* is relative to projectRoot/app (or projectRoot/pages). Otherwise falls back
* to path heuristics. e.g. "/abs/path/app/api/posts/[postId]" -> "/api/posts/[postId]"
*
* @param directory - Absolute path to the config directory.
* @param projectRoot - Optional directory containing package.json; when set, route path is derived from it.
*/
resolveRoutePath(directory: string): string;
resolveRoutePath(directory: string, projectRoot?: string | null): string;
/**
* Resolve the absolute path of the generated output directory.
*
Expand Down
43 changes: 43 additions & 0 deletions packages/route-action-gen/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,38 @@ describe("NextAppRouterGenerator", () => {
generator.resolveRoutePath("/project/apps/web/app/api/users"),
).toBe("/api/users");
});

it("uses projectRoot when path contains /app/ more than once (Docker or monorepo)", () => {
// Act
const result = generator.resolveRoutePath(
"/app/apps/hermes/app/dashboard/tickers/actions/import",
"/app/apps/hermes",
);

// Assert: route path is relative to projectRoot/app, not the first /app/
expect(result).toBe("/dashboard/tickers/actions/import");
});

it("falls back to lastIndexOf when projectRoot is not provided", () => {
// Act: same path as above but no projectRoot
const result = generator.resolveRoutePath(
"/app/apps/hermes/app/dashboard/tickers/actions/import",
);

// Assert: fallback uses last /app/ so we still get the Next.js route path
expect(result).toBe("/dashboard/tickers/actions/import");
});

it("uses projectRoot with src/app when Next.js project uses src directory", () => {
// Act
const result = generator.resolveRoutePath(
"/project/src/app/api/users",
"/project",
);

// Assert: route path is relative to projectRoot/src/app
expect(result).toBe("/api/users");
});
});

describe("generate", () => {
Expand Down Expand Up @@ -705,6 +737,17 @@ describe("NextPagesRouterGenerator", () => {
"/api/users",
);
});

it("uses projectRoot with src/pages when Next.js project uses src directory", () => {
// Act
const result = generator.resolveRoutePath(
"/project/src/pages/api/users",
"/project",
);

// Assert: route path is relative to projectRoot/src/pages
expect(result).toBe("/api/users");
});
});

describe("generate", () => {
Expand Down