diff --git a/packages/vinext/src/routing/app-route-graph.ts b/packages/vinext/src/routing/app-route-graph.ts index 1e53fe97d..97abbfbbc 100644 --- a/packages/vinext/src/routing/app-route-graph.ts +++ b/packages/vinext/src/routing/app-route-graph.ts @@ -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: "." | ".." | "../.." | "..." */ @@ -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}`; } @@ -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; @@ -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 @@ -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 @@ -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, ); } @@ -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; @@ -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; }); } @@ -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; @@ -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, @@ -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 @@ -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) { @@ -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( @@ -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); @@ -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; } diff --git a/packages/vinext/src/routing/file-matcher.ts b/packages/vinext/src/routing/file-matcher.ts index 5ba1f091a..0d741e5f2 100644 --- a/packages/vinext/src/routing/file-matcher.ts +++ b/packages/vinext/src/routing/file-matcher.ts @@ -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; @@ -139,6 +140,6 @@ export async function* scanWithExtensions( cwd, ...(exclude ? { exclude } : {}), })) { - yield file; + yield normalizePathSeparators(file); } } diff --git a/packages/vinext/src/routing/pages-router.ts b/packages/vinext/src/routing/pages-router.ts index 8ee638844..9072399f7 100644 --- a/packages/vinext/src/routing/pages-router.ts +++ b/packages/vinext/src/routing/pages-router.ts @@ -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"; @@ -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. */ @@ -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]; @@ -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, }; diff --git a/tests/routing-windows-paths.test.ts b/tests/routing-windows-paths.test.ts new file mode 100644 index 000000000..851505939 --- /dev/null +++ b/tests/routing-windows-paths.test.ts @@ -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(); + + 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"], + }); + }); +});