Skip to content
Draft
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
47 changes: 27 additions & 20 deletions packages/vinext/src/routing/app-route-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createHash } from "node:crypto";
import { compareRoutes, decodeRouteSegment, isInvisibleSegment } from "./utils.js";
import { scanWithExtensions, type ValidFileMatcher } from "./file-matcher.js";
import { validateRoutePatterns } from "./route-validation.js";
import { normalizePathSeparators } from "../utils/path.js";

export type InterceptingRoute = {
/** The interception convention: "." | ".." | "../.." | "..." */
Expand Down Expand Up @@ -970,10 +971,18 @@ function validatePageRouteConflicts(routes: readonly AppRoute[], appDir: string)
}
}

function splitFileRoutePath(filePath: string): string[] {
return normalizePathSeparators(filePath).split("/").filter(Boolean);
}

function joinRouteFilePath(root: string, filePath: string): string {
return normalizePathSeparators(path.join(root, ...splitFileRoutePath(filePath)));
}

function formatAppFilePath(filePath: string, appDir: string): string {
const relativePath = path.relative(appDir, filePath).replace(/\\/g, "/");
const relativePath = normalizePathSeparators(path.relative(appDir, filePath));
const parsedPath = path.parse(relativePath);
const withoutExtension = path.join(parsedPath.dir, parsedPath.name).replace(/\\/g, "/");
const withoutExtension = normalizePathSeparators(path.join(parsedPath.dir, parsedPath.name));
return withoutExtension.startsWith("/") ? withoutExtension : `/${withoutExtension}`;
}

Expand Down Expand Up @@ -1058,7 +1067,7 @@ function discoverSlotSubRoutes(

const subPages = findSlotSubPages(slotDir, matcher);
for (const { relativePath, pagePath } of subPages) {
const subSegments = relativePath.split(path.sep);
const subSegments = splitFileRoutePath(relativePath);
const convertedSubRoute = convertSegmentsToRouteParts(subSegments);
if (!convertedSubRoute) continue;

Expand Down Expand Up @@ -1223,7 +1232,7 @@ function findSlotSubPages(slotDir: string, matcher: ValidFileMatcher): SlotSubPa
const subDir = path.join(dir, entry.name);
const page = findFile(subDir, "page", matcher);
if (page) {
const relativePath = path.relative(slotDir, subDir);
const relativePath = normalizePathSeparators(path.relative(slotDir, subDir));
results.push({ relativePath, pagePath: page });
}
// Continue scanning deeper for nested sub-pages
Expand All @@ -1245,8 +1254,9 @@ function fileToAppRoute(
type: "page" | "route",
matcher: ValidFileMatcher,
): AppRouteGraphRoute | null {
const normalizedFile = normalizePathSeparators(file);
// Remove the filename (page.tsx or route.ts)
let dir = path.dirname(file);
let dir = path.posix.dirname(normalizedFile);

// `@children` is transparent in routing: `app/foo/@children/page.tsx`
// provides the children prop for `/foo` and registers a real page route
Expand All @@ -1265,8 +1275,8 @@ function fileToAppRoute(
dir,
appDir,
matcher,
type === "page" ? path.join(appDir, file) : null,
type === "route" ? path.join(appDir, file) : null,
type === "page" ? joinRouteFilePath(appDir, normalizedFile) : null,
type === "route" ? joinRouteFilePath(appDir, normalizedFile) : null,
);
}

Expand All @@ -1277,7 +1287,7 @@ function directoryToAppRoute(
pagePath: string | null,
routePath: string | null,
): AppRouteGraphRoute | null {
const segments = dir === "." ? [] : dir.split(path.sep);
const segments = dir === "." ? [] : splitFileRoutePath(dir);

const params: string[] = [];
let isDynamic = false;
Expand Down Expand Up @@ -1466,7 +1476,7 @@ function computeLayoutTreePositions(appDir: string, layouts: string[]): number[]
const layoutDir = path.dirname(layoutPath);
if (layoutDir === appDir) return 0;
const relative = path.relative(appDir, layoutDir);
return relative.split(path.sep).length;
return splitFileRoutePath(relative).length;
});
}

Expand Down Expand Up @@ -1781,7 +1791,7 @@ function findMirroredSlotPage(
};
let best: Candidate | null = null;
for (const { relativePath, pagePath } of findSlotSubPages(slotDir, matcher)) {
const slotSegments = relativePath.split(path.sep);
const slotSegments = splitFileRoutePath(relativePath);
const slotUrl = convertSegmentsToRouteParts(slotSegments);
if (!slotUrl) continue;
if (!patternsCompatible(slotUrl.urlSegments, routeUrl.urlSegments)) continue;
Expand Down Expand Up @@ -1909,15 +1919,12 @@ function discoverParallelSlots(
// Only include slots that have at least a page, default, or intercepting route
if (!pagePath && !defaultPath && interceptingRoutes.length === 0) continue;

const ownerSegments = path
.relative(appDir, dir)
.split(path.sep)
.filter((segment) => segment.length > 0);
const ownerSegments = splitFileRoutePath(path.relative(appDir, dir));
const ownerTreePath = createAppRouteGraphTreePath(ownerSegments, ownerSegments.length);

slots.push({
id: createAppRouteGraphSlotId(slotName, ownerTreePath),
key: `${slotName}@${path.relative(appDir, slotDir).replace(/\\/g, "/")}`,
key: `${slotName}@${normalizePathSeparators(path.relative(appDir, slotDir))}`,
name: slotName,
ownerDir: slotDir,
ownerTreePath,
Expand Down Expand Up @@ -2143,7 +2150,7 @@ function collectInterceptingPages(
* @see https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/interception-routes.ts
*/
function computeInterceptSourceMatchPattern(interceptParentDir: string, appDir: string): string {
const segments = path.relative(appDir, interceptParentDir).split(path.sep).filter(Boolean);
const segments = splitFileRoutePath(path.relative(appDir, interceptParentDir));
const converted = convertSegmentsToRouteParts(segments);
const urlSegments = converted
? converted.urlSegments
Expand Down Expand Up @@ -2180,7 +2187,7 @@ function computeInterceptTarget(
// Determine the base segments for target resolution.
// We work on route segments (not filesystem paths) so that route groups
// and parallel slots are properly skipped when climbing.
const routeSegments = path.relative(appDir, routeDir).split(path.sep).filter(Boolean);
const routeSegments = splitFileRoutePath(path.relative(appDir, routeDir));

let baseParts: string[];
switch (convention) {
Expand All @@ -2203,7 +2210,7 @@ function computeInterceptTarget(
routeSegments,
convention,
interceptSegment,
path.relative(interceptRoot, currentDir).split(path.sep).filter(Boolean),
splitFileRoutePath(path.relative(interceptRoot, currentDir)),
);
if (convention === "..") {
throw new Error(
Expand All @@ -2225,7 +2232,7 @@ function computeInterceptTarget(
}

// Add the intercept segment and any nested path segments
const nestedParts = path.relative(interceptRoot, currentDir).split(path.sep).filter(Boolean);
const nestedParts = splitFileRoutePath(path.relative(interceptRoot, currentDir));
const allSegments = [...baseParts, interceptSegment, ...nestedParts];

const convertedTarget = convertSegmentsToRouteParts(allSegments);
Expand Down Expand Up @@ -2276,7 +2283,7 @@ function markerForInterceptionConvention(convention: string): string {
function findFile(dir: string, name: string, matcher: ValidFileMatcher): string | null {
for (const ext of matcher.dottedExtensions) {
const filePath = path.join(dir, name + ext);
if (fs.existsSync(filePath)) return filePath;
if (fs.existsSync(filePath)) return normalizePathSeparators(filePath);
}
return null;
}
Expand Down
3 changes: 2 additions & 1 deletion packages/vinext/src/routing/file-matcher.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync } from "node:fs";
import { glob } from "node:fs/promises";
import { normalizePathSeparators } from "../utils/path.js";

const DEFAULT_PAGE_EXTENSIONS = ["tsx", "ts", "jsx", "js"] as const;

Expand Down Expand Up @@ -139,6 +140,6 @@ export async function* scanWithExtensions(
cwd,
...(exclude ? { exclude } : {}),
})) {
yield file;
yield normalizePathSeparators(file);
}
}
13 changes: 11 additions & 2 deletions packages/vinext/src/routing/pages-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
scanWithExtensions,
type ValidFileMatcher,
} from "./file-matcher.js";
import { normalizePathSeparators } from "../utils/path.js";
import { validateRoutePatterns } from "./route-validation.js";
import { createRouteTrieCache, matchRouteWithTrie } from "./route-matching.js";

Expand Down Expand Up @@ -93,6 +94,14 @@ async function scanPageRoutes(pagesDir: string, matcher: ValidFileMatcher): Prom
return routes;
}

function splitFileRoutePath(filePath: string): string[] {
return normalizePathSeparators(filePath).split("/").filter(Boolean);
}

function joinRouteFilePath(root: string, filePath: string): string {
return normalizePathSeparators(path.join(root, ...splitFileRoutePath(filePath)));
}

/**
* Convert a file path relative to pages/ into a Route.
*/
Expand All @@ -102,7 +111,7 @@ function fileToRoute(file: string, pagesDir: string, matcher: ValidFileMatcher):
if (withoutExt === file) return null;

// Convert to URL segments
const segments = withoutExt.split(path.sep);
const segments = splitFileRoutePath(withoutExt);

// Handle index files: pages/index.tsx -> /
const lastSegment = segments[segments.length - 1];
Expand Down Expand Up @@ -170,7 +179,7 @@ function fileToRoute(file: string, pagesDir: string, matcher: ValidFileMatcher):
return {
pattern: pattern === "/" ? "/" : pattern,
patternParts: urlSegments.filter(Boolean),
filePath: path.join(pagesDir, file),
filePath: joinRouteFilePath(pagesDir, file),
isDynamic,
params,
};
Expand Down
56 changes: 56 additions & 0 deletions tests/routing-windows-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js";

const WINDOWS_APP_DIR = "C:\\project\\app";
const WINDOWS_PAGES_DIR = "C:\\project\\pages";

vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();

return {
...actual,
async *glob(pattern: string, options?: { cwd?: string }) {
if (options?.cwd === WINDOWS_PAGES_DIR && pattern === "**/*.{tsx,ts,jsx,js}") {
yield "blog\\[slug].tsx";
}

if (options?.cwd === WINDOWS_APP_DIR && pattern === "**/page.{tsx,ts,jsx,js}") {
yield "shop\\[id]\\page.tsx";
}
},
};
});

describe("Windows filesystem paths", () => {
it("normalizes Pages Router glob results before deriving routes", async () => {
const { pagesRouter } = await import("../packages/vinext/src/routing/pages-router.js");

const routes = await pagesRouter(WINDOWS_PAGES_DIR, undefined, createValidFileMatcher());

expect(routes).toHaveLength(1);
expect(routes[0]).toMatchObject({
pattern: "/blog/:slug",
patternParts: ["blog", ":slug"],
filePath: "C:/project/pages/blog/[slug].tsx",
isDynamic: true,
params: ["slug"],
});
});

it("normalizes App Router glob results before deriving route graph paths", async () => {
const { buildAppRouteGraph } =
await import("../packages/vinext/src/routing/app-route-graph.js");

const graph = await buildAppRouteGraph(WINDOWS_APP_DIR, createValidFileMatcher());

expect(graph.routes).toHaveLength(1);
expect(graph.routes[0]).toMatchObject({
pattern: "/shop/:id",
pagePath: "C:/project/app/shop/[id]/page.tsx",
routeSegments: ["shop", "[id]"],
patternParts: ["shop", ":id"],
isDynamic: true,
params: ["id"],
});
});
});