From 73e0637916c569ef1a6118048347b5f166876b06 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 00:15:03 +0200 Subject: [PATCH 01/11] build manifest function --- .../src/config/manifest/build-manifest.ts | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 packages/nextjs/src/config/manifest/build-manifest.ts diff --git a/packages/nextjs/src/config/manifest/build-manifest.ts b/packages/nextjs/src/config/manifest/build-manifest.ts new file mode 100644 index 000000000000..070abc5c3e9c --- /dev/null +++ b/packages/nextjs/src/config/manifest/build-manifest.ts @@ -0,0 +1,199 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export type RouteInfo = { + path: string; + dynamic: boolean; + pattern?: string; + paramNames?: string[]; +}; + +export type RouteManifest = { + dynamic: RouteInfo[]; + static: RouteInfo[]; +}; + +export type CreateRouteManifestOptions = { + // For starters we only support app router + appDirPath?: string; +}; + +let manifestCache: RouteManifest | null = null; +let lastAppDirPath: string | null = null; + +function isPageFile(filename: string): boolean { + return filename === 'page.tsx' || filename === 'page.jsx' || filename === 'page.ts' || filename === 'page.js'; +} + +function isRouteGroup(name: string): boolean { + return name.startsWith('(') && name.endsWith(')'); +} + +function getDynamicRouteSegment(name: string): string { + if (name.startsWith('[[...') && name.endsWith(']]')) { + // Optional catchall: [[...param]] + const paramName = name.slice(5, -2); // Remove [[... and ]] + return `:${paramName}*?`; // Mark with ? as optional + } else if (name.startsWith('[...') && name.endsWith(']')) { + // Required catchall: [...param] + const paramName = name.slice(4, -1); // Remove [... and ] + return `:${paramName}*`; + } else { + // Regular dynamic: [param] + return `:${name.slice(1, -1)}`; + } +} + +function buildRegexForDynamicRoute(routePath: string): { pattern: string; paramNames: string[] } { + const segments = routePath.split('/').filter(Boolean); + const regexSegments: string[] = []; + const paramNames: string[] = []; + + for (const segment of segments) { + if (segment.startsWith(':')) { + const paramName = segment.substring(1); + + if (paramName.endsWith('*?')) { + // Optional catchall: matches zero or more segments + const cleanParamName = paramName.slice(0, -2); + paramNames.push(cleanParamName); + regexSegments.push('(.*)'); + } else if (paramName.endsWith('*')) { + // Required catchall: matches one or more segments + const cleanParamName = paramName.slice(0, -1); + paramNames.push(cleanParamName); + regexSegments.push('(.+)'); + } else { + // Regular dynamic segment + paramNames.push(paramName); + regexSegments.push('([^/]+)'); + } + } else { + // Static segment + regexSegments.push(segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + } + } + + const pattern = `^/${regexSegments.join('/')}$`; + return { pattern, paramNames }; +} + +function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { + const routes: RouteInfo[] = []; + + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const pageFile = getHighestPriorityPageFile(entries); + + if (pageFile) { + const routePath = basePath || '/'; + const isDynamic = routePath.includes(':'); + + if (isDynamic) { + const { pattern, paramNames } = buildRegexForDynamicRoute(routePath); + routes.push({ + path: routePath, + dynamic: true, + pattern, + paramNames, + }); + } else { + routes.push({ + path: routePath, + dynamic: false, + }); + } + } + + for (const entry of entries) { + if (entry.isDirectory()) { + const fullPath = path.join(dir, entry.name); + + if (isRouteGroup(entry.name)) { + // Route groups don't affect the URL, just scan them + const subRoutes = scanAppDirectory(fullPath, basePath); + routes.push(...subRoutes); + continue; + } + + const isDynamic = entry.name.startsWith('[') && entry.name.endsWith(']'); + let routeSegment: string; + + if (isDynamic) { + routeSegment = getDynamicRouteSegment(entry.name); + } else { + routeSegment = entry.name; + } + + const newBasePath = `${basePath}/${routeSegment}`; + const subRoutes = scanAppDirectory(fullPath, newBasePath); + routes.push(...subRoutes); + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.warn('Error building route manifest:', error); + } + + return routes; +} + +function getHighestPriorityPageFile(entries: fs.Dirent[]): string | null { + // Next.js precedence order: .tsx > .ts > .jsx > .js + const pageFiles = entries.filter(entry => entry.isFile() && isPageFile(entry.name)).map(entry => entry.name); + + if (pageFiles.length === 0) return null; + + if (pageFiles.includes('page.tsx')) return 'page.tsx'; + if (pageFiles.includes('page.ts')) return 'page.ts'; + if (pageFiles.includes('page.jsx')) return 'page.jsx'; + if (pageFiles.includes('page.js')) return 'page.js'; + + return null; +} + +/** + * Returns a route manifest for the given app directory + */ +export function createRouteManifest(options?: CreateRouteManifestOptions): RouteManifest { + let targetDir: string | undefined; + + if (options?.appDirPath) { + targetDir = options.appDirPath; + } else { + const projectDir = process.cwd(); + const maybeAppDirPath = path.join(projectDir, 'app'); + const maybeSrcAppDirPath = path.join(projectDir, 'src', 'app'); + + if (fs.existsSync(maybeAppDirPath) && fs.lstatSync(maybeAppDirPath).isDirectory()) { + targetDir = maybeAppDirPath; + } else if (fs.existsSync(maybeSrcAppDirPath) && fs.lstatSync(maybeSrcAppDirPath).isDirectory()) { + targetDir = maybeSrcAppDirPath; + } + } + + if (!targetDir) { + return { + dynamic: [], + static: [], + }; + } + + // Check if we can use cached version + if (manifestCache && lastAppDirPath === targetDir) { + return manifestCache; + } + + const routes = scanAppDirectory(targetDir); + + const manifest: RouteManifest = { + dynamic: routes.filter(route => route.dynamic), + static: routes.filter(route => !route.dynamic), + }; + + // set cache + manifestCache = manifest; + lastAppDirPath = targetDir; + + return manifest; +} From 9b28d799004624ac73cd71d07aff5a02f1cd523a Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 00:15:33 +0200 Subject: [PATCH 02/11] tests --- .../app/catchall/[[...path]]/page.tsx | 1 + .../manifest/suites/catchall/app/page.tsx | 1 + .../manifest/suites/catchall/catchall.test.ts | 32 +++++++ .../suites/dynamic/app/dynamic/[id]/page.tsx | 1 + .../manifest/suites/dynamic/app/page.tsx | 1 + .../suites/dynamic/app/static/nested/page.tsx | 1 + .../suites/dynamic/app/users/[id]/page.tsx | 1 + .../app/users/[id]/posts/[postId]/page.tsx | 1 + .../dynamic/app/users/[id]/settings/page.tsx | 1 + .../manifest/suites/dynamic/dynamic.test.ts | 92 +++++++++++++++++++ .../app/javascript/component.tsx | 1 + .../file-extensions/app/javascript/page.js | 1 + .../file-extensions/app/jsx-route/page.jsx | 1 + .../suites/file-extensions/app/layout.tsx | 1 + .../suites/file-extensions/app/mixed/page.jsx | 1 + .../suites/file-extensions/app/mixed/page.ts | 1 + .../suites/file-extensions/app/page.tsx | 1 + .../file-extensions/app/precedence/page.js | 1 + .../file-extensions/app/precedence/page.tsx | 1 + .../file-extensions/app/typescript/other.ts | 1 + .../file-extensions/app/typescript/page.ts | 1 + .../file-extensions/file-extensions.test.ts | 21 +++++ .../suites/route-groups/app/(auth)/layout.tsx | 1 + .../route-groups/app/(auth)/login/page.tsx | 1 + .../route-groups/app/(auth)/signup/page.tsx | 1 + .../app/(dashboard)/dashboard/[id]/page.tsx | 1 + .../app/(dashboard)/dashboard/page.tsx | 1 + .../route-groups/app/(dashboard)/layout.tsx | 1 + .../app/(dashboard)/settings/layout.tsx | 1 + .../app/(dashboard)/settings/profile/page.tsx | 1 + .../route-groups/app/(marketing)/layout.tsx | 1 + .../app/(marketing)/public/about/page.tsx | 1 + .../suites/route-groups/app/layout.tsx | 1 + .../manifest/suites/route-groups/app/page.tsx | 1 + .../suites/route-groups/route-groups.test.ts | 36 ++++++++ .../manifest/suites/static/app/page.tsx | 1 + .../suites/static/app/some/nested/page.tsx | 1 + .../manifest/suites/static/app/user/page.tsx | 1 + .../manifest/suites/static/app/users/page.tsx | 1 + .../manifest/suites/static/static.test.ts | 18 ++++ 40 files changed, 234 insertions(+) create mode 100644 packages/nextjs/test/config/manifest/suites/catchall/app/catchall/[[...path]]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/catchall/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/[id]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/static/nested/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/posts/[postId]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/settings/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/component.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/page.js create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/jsx-route/page.jsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.jsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.ts create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.js create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/other.ts create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/page.ts create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/login/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/signup/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/[id]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/profile/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/public/about/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts create mode 100644 packages/nextjs/test/config/manifest/suites/static/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/static/app/some/nested/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/static/app/user/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/static/app/users/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/static/static.test.ts diff --git a/packages/nextjs/test/config/manifest/suites/catchall/app/catchall/[[...path]]/page.tsx b/packages/nextjs/test/config/manifest/suites/catchall/app/catchall/[[...path]]/page.tsx new file mode 100644 index 000000000000..5d33b5d14573 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/catchall/app/catchall/[[...path]]/page.tsx @@ -0,0 +1 @@ +// beep diff --git a/packages/nextjs/test/config/manifest/suites/catchall/app/page.tsx b/packages/nextjs/test/config/manifest/suites/catchall/app/page.tsx new file mode 100644 index 000000000000..2145a5eea70d --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/catchall/app/page.tsx @@ -0,0 +1 @@ +// Ciao diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts new file mode 100644 index 000000000000..aaa4a900f67f --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -0,0 +1,32 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; + +describe('catchall', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + + test('should generate a manifest with catchall route', () => { + expect(manifest).toEqual({ + dynamic: [ + { + path: '/catchall/:path*?', + dynamic: true, + pattern: '^/catchall/(.*)$', + paramNames: ['path'], + }, + ], + static: [{ path: '/', dynamic: false }], + }); + }); + + test('should generate correct pattern for catchall route', () => { + const regex = new RegExp(manifest.dynamic[0]?.pattern ?? ''); + expect(regex.test('/catchall/123')).toBe(true); + expect(regex.test('/catchall/abc')).toBe(true); + expect(regex.test('/catchall/123/456')).toBe(true); + expect(regex.test('/catchall/123/abc/789')).toBe(true); + expect(regex.test('/catchall/')).toBe(true); + expect(regex.test('/123/catchall/123')).toBe(false); + expect(regex.test('/')).toBe(false); + }); +}); diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/[id]/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/[id]/page.tsx new file mode 100644 index 000000000000..5d33b5d14573 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/[id]/page.tsx @@ -0,0 +1 @@ +// beep diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/page.tsx new file mode 100644 index 000000000000..2145a5eea70d --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/page.tsx @@ -0,0 +1 @@ +// Ciao diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/static/nested/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/static/nested/page.tsx new file mode 100644 index 000000000000..c3a94a1cb9e7 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/static/nested/page.tsx @@ -0,0 +1 @@ +// Hola diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/page.tsx new file mode 100644 index 000000000000..262ed4b5bade --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/page.tsx @@ -0,0 +1 @@ +// User profile page diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/posts/[postId]/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/posts/[postId]/page.tsx new file mode 100644 index 000000000000..1b8e79363a7f --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/posts/[postId]/page.tsx @@ -0,0 +1 @@ +// Post detail page diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/settings/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/settings/page.tsx new file mode 100644 index 000000000000..2a09cffc75c4 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/settings/page.tsx @@ -0,0 +1 @@ +// User settings page diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts new file mode 100644 index 000000000000..267f5aa374f1 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -0,0 +1,92 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; + +describe('dynamic', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + + test('should generate a comprehensive dynamic manifest', () => { + expect(manifest).toEqual({ + dynamic: [ + { + path: '/dynamic/:id', + dynamic: true, + pattern: '^/dynamic/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id', + dynamic: true, + pattern: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id/posts/:postId', + dynamic: true, + pattern: '^/users/([^/]+)/posts/([^/]+)$', + paramNames: ['id', 'postId'], + }, + { + path: '/users/:id/settings', + dynamic: true, + pattern: '^/users/([^/]+)/settings$', + paramNames: ['id'], + }, + ], + static: [ + { path: '/', dynamic: false }, + { path: '/static/nested', dynamic: false }, + ], + }); + }); + + test('should generate correct pattern for single dynamic route', () => { + const singleDynamic = manifest.dynamic.find(route => route.path === '/dynamic/:id'); + const regex = new RegExp(singleDynamic?.pattern ?? ''); + expect(regex.test('/dynamic/123')).toBe(true); + expect(regex.test('/dynamic/abc')).toBe(true); + expect(regex.test('/dynamic/123/456')).toBe(false); + expect(regex.test('/dynamic123/123')).toBe(false); + expect(regex.test('/')).toBe(false); + }); + + test('should generate correct pattern for mixed static-dynamic route', () => { + const mixedRoute = manifest.dynamic.find(route => route.path === '/users/:id/settings'); + const regex = new RegExp(mixedRoute?.pattern ?? ''); + + expect(regex.test('/users/123/settings')).toBe(true); + expect(regex.test('/users/john-doe/settings')).toBe(true); + expect(regex.test('/users/123/settings/extra')).toBe(false); + expect(regex.test('/users/123')).toBe(false); + expect(regex.test('/settings')).toBe(false); + }); + + test('should generate correct pattern for multiple dynamic segments', () => { + const multiDynamic = manifest.dynamic.find(route => route.path === '/users/:id/posts/:postId'); + const regex = new RegExp(multiDynamic?.pattern ?? ''); + + expect(regex.test('/users/123/posts/456')).toBe(true); + expect(regex.test('/users/john/posts/my-post')).toBe(true); + expect(regex.test('/users/123/posts/456/comments')).toBe(false); + expect(regex.test('/users/123/posts')).toBe(false); + expect(regex.test('/users/123')).toBe(false); + + const match = '/users/123/posts/456'.match(regex); + expect(match).toBeTruthy(); + expect(match?.[1]).toBe('123'); + expect(match?.[2]).toBe('456'); + }); + + test('should handle special characters in dynamic segments', () => { + // Test that dynamic segments with special characters work properly + const userSettingsRoute = manifest.dynamic.find(route => route.path === '/users/:id/settings'); + expect(userSettingsRoute).toBeDefined(); + expect(userSettingsRoute?.pattern).toBeDefined(); + + const regex = new RegExp(userSettingsRoute!.pattern!); + expect(regex.test('/users/user-with-dashes/settings')).toBe(true); + expect(regex.test('/users/user_with_underscores/settings')).toBe(true); + expect(regex.test('/users/123/settings')).toBe(true); + expect(regex.test('/users/123/settings/extra')).toBe(false); + }); +}); diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/component.tsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/component.tsx new file mode 100644 index 000000000000..71f2fabe4ab9 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/component.tsx @@ -0,0 +1 @@ +// Component file - should be ignored diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/page.js b/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/page.js new file mode 100644 index 000000000000..648c2fc1a572 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/page.js @@ -0,0 +1 @@ +// JavaScript page diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/jsx-route/page.jsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/jsx-route/page.jsx new file mode 100644 index 000000000000..de9dad9da3f1 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/jsx-route/page.jsx @@ -0,0 +1 @@ +// JSX page diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/layout.tsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/layout.tsx new file mode 100644 index 000000000000..126ada0403af --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/layout.tsx @@ -0,0 +1 @@ +// Layout file - should be ignored diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.jsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.jsx new file mode 100644 index 000000000000..de9dad9da3f1 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.jsx @@ -0,0 +1 @@ +// JSX page diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.ts new file mode 100644 index 000000000000..9d0c3f668b0f --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.ts @@ -0,0 +1 @@ +// TypeScript page diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/page.tsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/page.tsx new file mode 100644 index 000000000000..7c6102bf4455 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/page.tsx @@ -0,0 +1 @@ +// Root page - TypeScript JSX diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.js b/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.js new file mode 100644 index 000000000000..c88b431881a8 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.js @@ -0,0 +1 @@ +// JavaScript page - should be ignored if tsx exists diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.tsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.tsx new file mode 100644 index 000000000000..b94cece5634c --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.tsx @@ -0,0 +1 @@ +// TypeScript JSX page - should take precedence diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/other.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/other.ts new file mode 100644 index 000000000000..1838a98702b5 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/other.ts @@ -0,0 +1 @@ +// Other TypeScript file - should be ignored diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/page.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/page.ts new file mode 100644 index 000000000000..9d0c3f668b0f --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/page.ts @@ -0,0 +1 @@ +// TypeScript page diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts new file mode 100644 index 000000000000..b6a7e6a2b728 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts @@ -0,0 +1,21 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; + +describe('file-extensions', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + + test('should detect page files with all supported extensions', () => { + expect(manifest).toEqual({ + dynamic: [], + static: [ + { path: '/', dynamic: false }, + { path: '/javascript', dynamic: false }, + { path: '/jsx-route', dynamic: false }, + { path: '/mixed', dynamic: false }, + { path: '/precedence', dynamic: false }, + { path: '/typescript', dynamic: false }, + ], + }); + }); +}); diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/layout.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/layout.tsx new file mode 100644 index 000000000000..c946d3fcf92f --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/layout.tsx @@ -0,0 +1 @@ +// Auth layout diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/login/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/login/page.tsx new file mode 100644 index 000000000000..ca3839e2b57a --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/login/page.tsx @@ -0,0 +1 @@ +// Login page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/signup/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/signup/page.tsx new file mode 100644 index 000000000000..9283a04ddf23 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/signup/page.tsx @@ -0,0 +1 @@ +// Signup page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/[id]/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/[id]/page.tsx new file mode 100644 index 000000000000..f5c50b6ae225 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/[id]/page.tsx @@ -0,0 +1 @@ +// Dynamic dashboard page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 000000000000..76e06b75c3d1 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1 @@ +// Dashboard page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/layout.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/layout.tsx new file mode 100644 index 000000000000..0277c5a9bfce --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/layout.tsx @@ -0,0 +1 @@ +// Dashboard layout diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/layout.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/layout.tsx new file mode 100644 index 000000000000..80acdce1ca66 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/layout.tsx @@ -0,0 +1 @@ +// Settings layout diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/profile/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/profile/page.tsx new file mode 100644 index 000000000000..f715804e06c7 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/profile/page.tsx @@ -0,0 +1 @@ +// Settings profile page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/layout.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/layout.tsx new file mode 100644 index 000000000000..3242dbd8f393 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/layout.tsx @@ -0,0 +1 @@ +// Marketing layout diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/public/about/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/public/about/page.tsx new file mode 100644 index 000000000000..8088810f2e5a --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/public/about/page.tsx @@ -0,0 +1 @@ +// About page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/layout.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/layout.tsx new file mode 100644 index 000000000000..0490aba2e801 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/layout.tsx @@ -0,0 +1 @@ +// Root layout diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/page.tsx new file mode 100644 index 000000000000..7a8d1d44737d --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/page.tsx @@ -0,0 +1 @@ +// Root page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts new file mode 100644 index 000000000000..9a882992b8a1 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -0,0 +1,36 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; + +describe('route-groups', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + + test('should generate a manifest with route groups', () => { + expect(manifest).toEqual({ + dynamic: [ + { + path: '/dashboard/:id', + dynamic: true, + pattern: '^/dashboard/([^/]+)$', + paramNames: ['id'], + }, + ], + static: [ + { path: '/', dynamic: false }, + { path: '/login', dynamic: false }, + { path: '/signup', dynamic: false }, + { path: '/dashboard', dynamic: false }, + { path: '/settings/profile', dynamic: false }, + { path: '/public/about', dynamic: false }, + ], + }); + }); + + test('should handle dynamic routes within route groups', () => { + const dynamicRoute = manifest.dynamic.find(route => route.path.includes('/dashboard/:id')); + const regex = new RegExp(dynamicRoute?.pattern ?? ''); + expect(regex.test('/dashboard/123')).toBe(true); + expect(regex.test('/dashboard/abc')).toBe(true); + expect(regex.test('/dashboard/123/456')).toBe(false); + }); +}); diff --git a/packages/nextjs/test/config/manifest/suites/static/app/page.tsx b/packages/nextjs/test/config/manifest/suites/static/app/page.tsx new file mode 100644 index 000000000000..2145a5eea70d --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/static/app/page.tsx @@ -0,0 +1 @@ +// Ciao diff --git a/packages/nextjs/test/config/manifest/suites/static/app/some/nested/page.tsx b/packages/nextjs/test/config/manifest/suites/static/app/some/nested/page.tsx new file mode 100644 index 000000000000..c3a94a1cb9e7 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/static/app/some/nested/page.tsx @@ -0,0 +1 @@ +// Hola diff --git a/packages/nextjs/test/config/manifest/suites/static/app/user/page.tsx b/packages/nextjs/test/config/manifest/suites/static/app/user/page.tsx new file mode 100644 index 000000000000..5d33b5d14573 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/static/app/user/page.tsx @@ -0,0 +1 @@ +// beep diff --git a/packages/nextjs/test/config/manifest/suites/static/app/users/page.tsx b/packages/nextjs/test/config/manifest/suites/static/app/users/page.tsx new file mode 100644 index 000000000000..6723592cc451 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/static/app/users/page.tsx @@ -0,0 +1 @@ +// boop diff --git a/packages/nextjs/test/config/manifest/suites/static/static.test.ts b/packages/nextjs/test/config/manifest/suites/static/static.test.ts new file mode 100644 index 000000000000..72b97018bed8 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/static/static.test.ts @@ -0,0 +1,18 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; + +describe('simple', () => { + test('should generate a static manifest', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + expect(manifest).toEqual({ + dynamic: [], + static: [ + { path: '/', dynamic: false }, + { path: '/some/nested', dynamic: false }, + { path: '/user', dynamic: false }, + { path: '/users', dynamic: false }, + ], + }); + }); +}); From 657dd550b6ec5028cc0d97998ca29924be3a67b5 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 00:21:35 +0200 Subject: [PATCH 03/11] rename file --- .../{build-manifest.ts => buildManifest.ts} | 0 .../fixtures/static copy/static.test.ts | 18 ++++++++++++++++++ .../manifest/suites/catchall/catchall.test.ts | 2 +- .../manifest/suites/dynamic/dynamic.test.ts | 2 +- .../file-extensions/file-extensions.test.ts | 2 +- .../suites/route-groups/route-groups.test.ts | 2 +- .../manifest/suites/static/static.test.ts | 4 ++-- 7 files changed, 24 insertions(+), 6 deletions(-) rename packages/nextjs/src/config/manifest/{build-manifest.ts => buildManifest.ts} (100%) create mode 100644 packages/nextjs/test/config/manifest/fixtures/static copy/static.test.ts diff --git a/packages/nextjs/src/config/manifest/build-manifest.ts b/packages/nextjs/src/config/manifest/buildManifest.ts similarity index 100% rename from packages/nextjs/src/config/manifest/build-manifest.ts rename to packages/nextjs/src/config/manifest/buildManifest.ts diff --git a/packages/nextjs/test/config/manifest/fixtures/static copy/static.test.ts b/packages/nextjs/test/config/manifest/fixtures/static copy/static.test.ts new file mode 100644 index 000000000000..07399f317736 --- /dev/null +++ b/packages/nextjs/test/config/manifest/fixtures/static copy/static.test.ts @@ -0,0 +1,18 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; + +describe('simple', () => { + test('should generate a static manifest', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + expect(manifest).toEqual({ + dynamic: [], + static: [ + { path: '/', dynamic: false }, + { path: '/some/nested', dynamic: false }, + { path: '/user', dynamic: false }, + { path: '/users', dynamic: false }, + ], + }); + }); +}); diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts index aaa4a900f67f..0504e8176988 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -1,6 +1,6 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; describe('catchall', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts index 267f5aa374f1..bf92a9d2bcbe 100644 --- a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -1,6 +1,6 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; describe('dynamic', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts index b6a7e6a2b728..f2bef75e8784 100644 --- a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts @@ -1,6 +1,6 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; describe('file-extensions', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts index 9a882992b8a1..e033bff2a8bc 100644 --- a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -1,6 +1,6 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; describe('route-groups', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); diff --git a/packages/nextjs/test/config/manifest/suites/static/static.test.ts b/packages/nextjs/test/config/manifest/suites/static/static.test.ts index 72b97018bed8..7f612e3ba86b 100644 --- a/packages/nextjs/test/config/manifest/suites/static/static.test.ts +++ b/packages/nextjs/test/config/manifest/suites/static/static.test.ts @@ -1,8 +1,8 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; -describe('simple', () => { +describe('static', () => { test('should generate a static manifest', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); expect(manifest).toEqual({ From a82b977e8576acd8a2f444d1a3f555bb5d97f394 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 00:30:15 +0200 Subject: [PATCH 04/11] simplify page check --- .../nextjs/src/config/manifest/buildManifest.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/nextjs/src/config/manifest/buildManifest.ts b/packages/nextjs/src/config/manifest/buildManifest.ts index 070abc5c3e9c..30186d522cb8 100644 --- a/packages/nextjs/src/config/manifest/buildManifest.ts +++ b/packages/nextjs/src/config/manifest/buildManifest.ts @@ -83,7 +83,7 @@ function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { try { const entries = fs.readdirSync(dir, { withFileTypes: true }); - const pageFile = getHighestPriorityPageFile(entries); + const pageFile = entries.some(entry => isPageFile(entry.name)); if (pageFile) { const routePath = basePath || '/'; @@ -138,20 +138,6 @@ function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { return routes; } -function getHighestPriorityPageFile(entries: fs.Dirent[]): string | null { - // Next.js precedence order: .tsx > .ts > .jsx > .js - const pageFiles = entries.filter(entry => entry.isFile() && isPageFile(entry.name)).map(entry => entry.name); - - if (pageFiles.length === 0) return null; - - if (pageFiles.includes('page.tsx')) return 'page.tsx'; - if (pageFiles.includes('page.ts')) return 'page.ts'; - if (pageFiles.includes('page.jsx')) return 'page.jsx'; - if (pageFiles.includes('page.js')) return 'page.js'; - - return null; -} - /** * Returns a route manifest for the given app directory */ From 7d6e198450065231fad5344bf5a82a6512ac17c0 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 10:29:32 +0200 Subject: [PATCH 05/11] que --- .../fixtures/static copy/static.test.ts | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 packages/nextjs/test/config/manifest/fixtures/static copy/static.test.ts diff --git a/packages/nextjs/test/config/manifest/fixtures/static copy/static.test.ts b/packages/nextjs/test/config/manifest/fixtures/static copy/static.test.ts deleted file mode 100644 index 07399f317736..000000000000 --- a/packages/nextjs/test/config/manifest/fixtures/static copy/static.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import path from 'path'; -import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; - -describe('simple', () => { - test('should generate a static manifest', () => { - const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); - expect(manifest).toEqual({ - dynamic: [], - static: [ - { path: '/', dynamic: false }, - { path: '/some/nested', dynamic: false }, - { path: '/user', dynamic: false }, - { path: '/users', dynamic: false }, - ], - }); - }); -}); From 71b1121bdb025088ca82be8116103b4653f4f0b4 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 11:11:58 +0200 Subject: [PATCH 06/11] fix handling optional catchall routes --- .../nextjs/src/config/manifest/buildManifest.ts | 15 +++++++++++++-- .../manifest/suites/catchall/catchall.test.ts | 3 ++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/src/config/manifest/buildManifest.ts b/packages/nextjs/src/config/manifest/buildManifest.ts index 30186d522cb8..9c309bc3098e 100644 --- a/packages/nextjs/src/config/manifest/buildManifest.ts +++ b/packages/nextjs/src/config/manifest/buildManifest.ts @@ -48,6 +48,7 @@ function buildRegexForDynamicRoute(routePath: string): { pattern: string; paramN const segments = routePath.split('/').filter(Boolean); const regexSegments: string[] = []; const paramNames: string[] = []; + let hasOptionalCatchall = false; for (const segment of segments) { if (segment.startsWith(':')) { @@ -57,7 +58,8 @@ function buildRegexForDynamicRoute(routePath: string): { pattern: string; paramN // Optional catchall: matches zero or more segments const cleanParamName = paramName.slice(0, -2); paramNames.push(cleanParamName); - regexSegments.push('(.*)'); + // Handling this special case in pattern construction below + hasOptionalCatchall = true; } else if (paramName.endsWith('*')) { // Required catchall: matches one or more segments const cleanParamName = paramName.slice(0, -1); @@ -74,7 +76,16 @@ function buildRegexForDynamicRoute(routePath: string): { pattern: string; paramN } } - const pattern = `^/${regexSegments.join('/')}$`; + let pattern: string; + if (hasOptionalCatchall) { + // For optional catchall, make the trailing slash and segments optional + // This allows matching both /catchall and /catchall/anything + const staticParts = regexSegments.join('/'); + pattern = `^/${staticParts}(?:/(.*))?$`; + } else { + pattern = `^/${regexSegments.join('/')}$`; + } + return { pattern, paramNames }; } diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts index 0504e8176988..2709d41dca12 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -11,7 +11,7 @@ describe('catchall', () => { { path: '/catchall/:path*?', dynamic: true, - pattern: '^/catchall/(.*)$', + pattern: '^/catchall(?:/(.*))?$', paramNames: ['path'], }, ], @@ -26,6 +26,7 @@ describe('catchall', () => { expect(regex.test('/catchall/123/456')).toBe(true); expect(regex.test('/catchall/123/abc/789')).toBe(true); expect(regex.test('/catchall/')).toBe(true); + expect(regex.test('/catchall')).toBe(true); expect(regex.test('/123/catchall/123')).toBe(false); expect(regex.test('/')).toBe(false); }); From 3114f5c01088f429c246ec0adabd72556224fdf3 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 12:40:49 +0200 Subject: [PATCH 07/11] update if else block Co-authored-by: Brice Friha <37577669+bricefriha@users.noreply.github.com> --- packages/nextjs/src/config/manifest/buildManifest.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/nextjs/src/config/manifest/buildManifest.ts b/packages/nextjs/src/config/manifest/buildManifest.ts index 9c309bc3098e..6de12d1bfd6b 100644 --- a/packages/nextjs/src/config/manifest/buildManifest.ts +++ b/packages/nextjs/src/config/manifest/buildManifest.ts @@ -38,10 +38,9 @@ function getDynamicRouteSegment(name: string): string { // Required catchall: [...param] const paramName = name.slice(4, -1); // Remove [... and ] return `:${paramName}*`; - } else { - // Regular dynamic: [param] - return `:${name.slice(1, -1)}`; - } + } + // Regular dynamic: [param] + return `:${name.slice(1, -1)}`; } function buildRegexForDynamicRoute(routePath: string): { pattern: string; paramNames: string[] } { From 75305f5180dcbbe00ddbad004fba4fc119161189 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 12:43:48 +0200 Subject: [PATCH 08/11] .. --- packages/nextjs/src/config/manifest/buildManifest.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/src/config/manifest/buildManifest.ts b/packages/nextjs/src/config/manifest/buildManifest.ts index 6de12d1bfd6b..e00f50585ae1 100644 --- a/packages/nextjs/src/config/manifest/buildManifest.ts +++ b/packages/nextjs/src/config/manifest/buildManifest.ts @@ -38,9 +38,9 @@ function getDynamicRouteSegment(name: string): string { // Required catchall: [...param] const paramName = name.slice(4, -1); // Remove [... and ] return `:${paramName}*`; - } - // Regular dynamic: [param] - return `:${name.slice(1, -1)}`; + } + // Regular dynamic: [param] + return `:${name.slice(1, -1)}`; } function buildRegexForDynamicRoute(routePath: string): { pattern: string; paramNames: string[] } { From e1056202520eb99d6a1827b4dd03164bd6866d90 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 16:58:34 +0200 Subject: [PATCH 09/11] update interface + rename func --- ...uildManifest.ts => createRouteManifest.ts} | 29 ++++--------- packages/nextjs/src/config/manifest/types.ts | 27 ++++++++++++ .../manifest/suites/catchall/catchall.test.ts | 12 +++--- .../manifest/suites/dynamic/dynamic.test.ts | 42 ++++++++----------- .../file-extensions/file-extensions.test.ts | 17 ++++---- .../suites/route-groups/route-groups.test.ts | 25 +++++------ .../manifest/suites/static/static.test.ts | 10 +---- 7 files changed, 79 insertions(+), 83 deletions(-) rename packages/nextjs/src/config/manifest/{buildManifest.ts => createRouteManifest.ts} (89%) create mode 100644 packages/nextjs/src/config/manifest/types.ts diff --git a/packages/nextjs/src/config/manifest/buildManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts similarity index 89% rename from packages/nextjs/src/config/manifest/buildManifest.ts rename to packages/nextjs/src/config/manifest/createRouteManifest.ts index e00f50585ae1..12c76f43e39b 100644 --- a/packages/nextjs/src/config/manifest/buildManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -1,17 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; - -export type RouteInfo = { - path: string; - dynamic: boolean; - pattern?: string; - paramNames?: string[]; -}; - -export type RouteManifest = { - dynamic: RouteInfo[]; - static: RouteInfo[]; -}; +import type { RouteInfo, RouteManifest } from './types'; export type CreateRouteManifestOptions = { // For starters we only support app router @@ -43,7 +32,7 @@ function getDynamicRouteSegment(name: string): string { return `:${name.slice(1, -1)}`; } -function buildRegexForDynamicRoute(routePath: string): { pattern: string; paramNames: string[] } { +function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNames: string[] } { const segments = routePath.split('/').filter(Boolean); const regexSegments: string[] = []; const paramNames: string[] = []; @@ -85,7 +74,7 @@ function buildRegexForDynamicRoute(routePath: string): { pattern: string; paramN pattern = `^/${regexSegments.join('/')}$`; } - return { pattern, paramNames }; + return { regex: pattern, paramNames }; } function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { @@ -100,17 +89,15 @@ function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { const isDynamic = routePath.includes(':'); if (isDynamic) { - const { pattern, paramNames } = buildRegexForDynamicRoute(routePath); + const { regex, paramNames } = buildRegexForDynamicRoute(routePath); routes.push({ path: routePath, - dynamic: true, - pattern, + regex, paramNames, }); } else { routes.push({ path: routePath, - dynamic: false, }); } } @@ -170,8 +157,7 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route if (!targetDir) { return { - dynamic: [], - static: [], + routes: [], }; } @@ -183,8 +169,7 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route const routes = scanAppDirectory(targetDir); const manifest: RouteManifest = { - dynamic: routes.filter(route => route.dynamic), - static: routes.filter(route => !route.dynamic), + routes, }; // set cache diff --git a/packages/nextjs/src/config/manifest/types.ts b/packages/nextjs/src/config/manifest/types.ts new file mode 100644 index 000000000000..a15d2098aebe --- /dev/null +++ b/packages/nextjs/src/config/manifest/types.ts @@ -0,0 +1,27 @@ +/** + * Information about a single route in the manifest + */ +export type RouteInfo = { + /** + * The parameterised route path, e.g. "/users/[id]" + */ + path: string; + /** + * (Optional) The regex pattern for dynamic routes + */ + regex?: string; + /** + * (Optional) The names of dynamic parameters in the route + */ + paramNames?: string[]; +}; + +/** + * The manifest containing all routes discovered in the app + */ +export type RouteManifest = { + /** + * List of all routes (static and dynamic) + */ + routes: RouteInfo[]; +}; diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts index 2709d41dca12..435157b07da7 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -1,26 +1,26 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; describe('catchall', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); test('should generate a manifest with catchall route', () => { expect(manifest).toEqual({ - dynamic: [ + routes: [ + { path: '/' }, { path: '/catchall/:path*?', - dynamic: true, - pattern: '^/catchall(?:/(.*))?$', + regex: '^/catchall(?:/(.*))?$', paramNames: ['path'], }, ], - static: [{ path: '/', dynamic: false }], }); }); test('should generate correct pattern for catchall route', () => { - const regex = new RegExp(manifest.dynamic[0]?.pattern ?? ''); + const catchallRoute = manifest.routes.find(route => route.path === '/catchall/:path*?'); + const regex = new RegExp(catchallRoute?.regex ?? ''); expect(regex.test('/catchall/123')).toBe(true); expect(regex.test('/catchall/abc')).toBe(true); expect(regex.test('/catchall/123/456')).toBe(true); diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts index bf92a9d2bcbe..400f8cc84821 100644 --- a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -1,48 +1,42 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; describe('dynamic', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); - test('should generate a comprehensive dynamic manifest', () => { + test('should generate a dynamic manifest', () => { expect(manifest).toEqual({ - dynamic: [ + routes: [ + { path: '/' }, { path: '/dynamic/:id', - dynamic: true, - pattern: '^/dynamic/([^/]+)$', + regex: '^/dynamic/([^/]+)$', paramNames: ['id'], }, + { path: '/static/nested' }, { path: '/users/:id', - dynamic: true, - pattern: '^/users/([^/]+)$', + regex: '^/users/([^/]+)$', paramNames: ['id'], }, { path: '/users/:id/posts/:postId', - dynamic: true, - pattern: '^/users/([^/]+)/posts/([^/]+)$', + regex: '^/users/([^/]+)/posts/([^/]+)$', paramNames: ['id', 'postId'], }, { path: '/users/:id/settings', - dynamic: true, - pattern: '^/users/([^/]+)/settings$', + regex: '^/users/([^/]+)/settings$', paramNames: ['id'], }, ], - static: [ - { path: '/', dynamic: false }, - { path: '/static/nested', dynamic: false }, - ], }); }); test('should generate correct pattern for single dynamic route', () => { - const singleDynamic = manifest.dynamic.find(route => route.path === '/dynamic/:id'); - const regex = new RegExp(singleDynamic?.pattern ?? ''); + const singleDynamic = manifest.routes.find(route => route.path === '/dynamic/:id'); + const regex = new RegExp(singleDynamic?.regex ?? ''); expect(regex.test('/dynamic/123')).toBe(true); expect(regex.test('/dynamic/abc')).toBe(true); expect(regex.test('/dynamic/123/456')).toBe(false); @@ -51,8 +45,8 @@ describe('dynamic', () => { }); test('should generate correct pattern for mixed static-dynamic route', () => { - const mixedRoute = manifest.dynamic.find(route => route.path === '/users/:id/settings'); - const regex = new RegExp(mixedRoute?.pattern ?? ''); + const mixedRoute = manifest.routes.find(route => route.path === '/users/:id/settings'); + const regex = new RegExp(mixedRoute?.regex ?? ''); expect(regex.test('/users/123/settings')).toBe(true); expect(regex.test('/users/john-doe/settings')).toBe(true); @@ -62,8 +56,8 @@ describe('dynamic', () => { }); test('should generate correct pattern for multiple dynamic segments', () => { - const multiDynamic = manifest.dynamic.find(route => route.path === '/users/:id/posts/:postId'); - const regex = new RegExp(multiDynamic?.pattern ?? ''); + const multiDynamic = manifest.routes.find(route => route.path === '/users/:id/posts/:postId'); + const regex = new RegExp(multiDynamic?.regex ?? ''); expect(regex.test('/users/123/posts/456')).toBe(true); expect(regex.test('/users/john/posts/my-post')).toBe(true); @@ -79,11 +73,11 @@ describe('dynamic', () => { test('should handle special characters in dynamic segments', () => { // Test that dynamic segments with special characters work properly - const userSettingsRoute = manifest.dynamic.find(route => route.path === '/users/:id/settings'); + const userSettingsRoute = manifest.routes.find(route => route.path === '/users/:id/settings'); expect(userSettingsRoute).toBeDefined(); - expect(userSettingsRoute?.pattern).toBeDefined(); + expect(userSettingsRoute?.regex).toBeDefined(); - const regex = new RegExp(userSettingsRoute!.pattern!); + const regex = new RegExp(userSettingsRoute!.regex!); expect(regex.test('/users/user-with-dashes/settings')).toBe(true); expect(regex.test('/users/user_with_underscores/settings')).toBe(true); expect(regex.test('/users/123/settings')).toBe(true); diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts index f2bef75e8784..b646e7fe994d 100644 --- a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts @@ -1,20 +1,19 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; describe('file-extensions', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); test('should detect page files with all supported extensions', () => { expect(manifest).toEqual({ - dynamic: [], - static: [ - { path: '/', dynamic: false }, - { path: '/javascript', dynamic: false }, - { path: '/jsx-route', dynamic: false }, - { path: '/mixed', dynamic: false }, - { path: '/precedence', dynamic: false }, - { path: '/typescript', dynamic: false }, + routes: [ + { path: '/' }, + { path: '/javascript' }, + { path: '/jsx-route' }, + { path: '/mixed' }, + { path: '/precedence' }, + { path: '/typescript' }, ], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts index e033bff2a8bc..7caa9621d342 100644 --- a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -1,34 +1,31 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; describe('route-groups', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); test('should generate a manifest with route groups', () => { expect(manifest).toEqual({ - dynamic: [ + routes: [ + { path: '/' }, + { path: '/login' }, + { path: '/signup' }, + { path: '/dashboard' }, { path: '/dashboard/:id', - dynamic: true, - pattern: '^/dashboard/([^/]+)$', + regex: '^/dashboard/([^/]+)$', paramNames: ['id'], }, - ], - static: [ - { path: '/', dynamic: false }, - { path: '/login', dynamic: false }, - { path: '/signup', dynamic: false }, - { path: '/dashboard', dynamic: false }, - { path: '/settings/profile', dynamic: false }, - { path: '/public/about', dynamic: false }, + { path: '/settings/profile' }, + { path: '/public/about' }, ], }); }); test('should handle dynamic routes within route groups', () => { - const dynamicRoute = manifest.dynamic.find(route => route.path.includes('/dashboard/:id')); - const regex = new RegExp(dynamicRoute?.pattern ?? ''); + const dynamicRoute = manifest.routes.find(route => route.path.includes('/dashboard/:id')); + const regex = new RegExp(dynamicRoute?.regex ?? ''); expect(regex.test('/dashboard/123')).toBe(true); expect(regex.test('/dashboard/abc')).toBe(true); expect(regex.test('/dashboard/123/456')).toBe(false); diff --git a/packages/nextjs/test/config/manifest/suites/static/static.test.ts b/packages/nextjs/test/config/manifest/suites/static/static.test.ts index 7f612e3ba86b..ca927457ad5d 100644 --- a/packages/nextjs/test/config/manifest/suites/static/static.test.ts +++ b/packages/nextjs/test/config/manifest/suites/static/static.test.ts @@ -1,18 +1,12 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; describe('static', () => { test('should generate a static manifest', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); expect(manifest).toEqual({ - dynamic: [], - static: [ - { path: '/', dynamic: false }, - { path: '/some/nested', dynamic: false }, - { path: '/user', dynamic: false }, - { path: '/users', dynamic: false }, - ], + routes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], }); }); }); From 6071f76a5f1156e2871c058ff74d24cc13ead5d5 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 11 Jul 2025 11:05:04 +0200 Subject: [PATCH 10/11] split routes again --- .../config/manifest/createRouteManifest.ts | 56 +++++++++++-------- packages/nextjs/src/config/manifest/types.ts | 9 ++- .../manifest/suites/catchall/catchall.test.ts | 6 +- .../dynamic/app/dynamic/static/page.tsx | 1 + .../manifest/suites/dynamic/dynamic.test.ts | 14 ++--- .../file-extensions/file-extensions.test.ts | 3 +- .../suites/route-groups/route-groups.test.ts | 10 ++-- .../manifest/suites/static/static.test.ts | 3 +- 8 files changed, 60 insertions(+), 42 deletions(-) create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/static/page.tsx diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index 12c76f43e39b..3723fbe77ce2 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -18,6 +18,11 @@ function isRouteGroup(name: string): boolean { return name.startsWith('(') && name.endsWith(')'); } +function normalizeRoutePath(routePath: string): string { + // Remove route group segments from the path + return routePath.replace(/\/\([^)]+\)/g, ''); +} + function getDynamicRouteSegment(name: string): string { if (name.startsWith('[[...') && name.endsWith(']]')) { // Optional catchall: [[...param]] @@ -77,27 +82,32 @@ function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNam return { regex: pattern, paramNames }; } -function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { - const routes: RouteInfo[] = []; +function scanAppDirectory( + dir: string, + basePath: string = '', +): { dynamicRoutes: RouteInfo[]; staticRoutes: RouteInfo[] } { + const dynamicRoutes: RouteInfo[] = []; + const staticRoutes: RouteInfo[] = []; try { const entries = fs.readdirSync(dir, { withFileTypes: true }); const pageFile = entries.some(entry => isPageFile(entry.name)); if (pageFile) { - const routePath = basePath || '/'; - const isDynamic = routePath.includes(':'); + // Normalize the path by removing route groups when creating the final route + const normalizedRoutePath = normalizeRoutePath(basePath || '/'); + const isDynamic = normalizedRoutePath.includes(':'); if (isDynamic) { - const { regex, paramNames } = buildRegexForDynamicRoute(routePath); - routes.push({ - path: routePath, + const { regex, paramNames } = buildRegexForDynamicRoute(normalizedRoutePath); + dynamicRoutes.push({ + path: normalizedRoutePath, regex, paramNames, }); } else { - routes.push({ - path: routePath, + staticRoutes.push({ + path: normalizedRoutePath, }); } } @@ -105,18 +115,14 @@ function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { for (const entry of entries) { if (entry.isDirectory()) { const fullPath = path.join(dir, entry.name); - - if (isRouteGroup(entry.name)) { - // Route groups don't affect the URL, just scan them - const subRoutes = scanAppDirectory(fullPath, basePath); - routes.push(...subRoutes); - continue; - } + let routeSegment: string; const isDynamic = entry.name.startsWith('[') && entry.name.endsWith(']'); - let routeSegment: string; + const isRouteGroupDir = isRouteGroup(entry.name); - if (isDynamic) { + if (isRouteGroupDir) { + routeSegment = entry.name; + } else if (isDynamic) { routeSegment = getDynamicRouteSegment(entry.name); } else { routeSegment = entry.name; @@ -124,7 +130,9 @@ function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { const newBasePath = `${basePath}/${routeSegment}`; const subRoutes = scanAppDirectory(fullPath, newBasePath); - routes.push(...subRoutes); + + dynamicRoutes.push(...subRoutes.dynamicRoutes); + staticRoutes.push(...subRoutes.staticRoutes); } } } catch (error) { @@ -132,7 +140,7 @@ function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { console.warn('Error building route manifest:', error); } - return routes; + return { dynamicRoutes, staticRoutes }; } /** @@ -157,7 +165,8 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route if (!targetDir) { return { - routes: [], + dynamicRoutes: [], + staticRoutes: [], }; } @@ -166,10 +175,11 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route return manifestCache; } - const routes = scanAppDirectory(targetDir); + const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir); const manifest: RouteManifest = { - routes, + dynamicRoutes, + staticRoutes, }; // set cache diff --git a/packages/nextjs/src/config/manifest/types.ts b/packages/nextjs/src/config/manifest/types.ts index a15d2098aebe..e3a26adfce2f 100644 --- a/packages/nextjs/src/config/manifest/types.ts +++ b/packages/nextjs/src/config/manifest/types.ts @@ -21,7 +21,12 @@ export type RouteInfo = { */ export type RouteManifest = { /** - * List of all routes (static and dynamic) + * List of all dynamic routes */ - routes: RouteInfo[]; + dynamicRoutes: RouteInfo[]; + + /** + * List of all static routes + */ + staticRoutes: RouteInfo[]; }; diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts index 435157b07da7..b1c417970ba4 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -7,8 +7,8 @@ describe('catchall', () => { test('should generate a manifest with catchall route', () => { expect(manifest).toEqual({ - routes: [ - { path: '/' }, + staticRoutes: [{ path: '/' }], + dynamicRoutes: [ { path: '/catchall/:path*?', regex: '^/catchall(?:/(.*))?$', @@ -19,7 +19,7 @@ describe('catchall', () => { }); test('should generate correct pattern for catchall route', () => { - const catchallRoute = manifest.routes.find(route => route.path === '/catchall/:path*?'); + const catchallRoute = manifest.dynamicRoutes.find(route => route.path === '/catchall/:path*?'); const regex = new RegExp(catchallRoute?.regex ?? ''); expect(regex.test('/catchall/123')).toBe(true); expect(regex.test('/catchall/abc')).toBe(true); diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/static/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/static/page.tsx new file mode 100644 index 000000000000..f0ba5f3c3b70 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/static/page.tsx @@ -0,0 +1 @@ +// Static diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts index 400f8cc84821..fdcae299d7cf 100644 --- a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -7,14 +7,13 @@ describe('dynamic', () => { test('should generate a dynamic manifest', () => { expect(manifest).toEqual({ - routes: [ - { path: '/' }, + staticRoutes: [{ path: '/' }, { path: '/dynamic/static' }, { path: '/static/nested' }], + dynamicRoutes: [ { path: '/dynamic/:id', regex: '^/dynamic/([^/]+)$', paramNames: ['id'], }, - { path: '/static/nested' }, { path: '/users/:id', regex: '^/users/([^/]+)$', @@ -35,7 +34,7 @@ describe('dynamic', () => { }); test('should generate correct pattern for single dynamic route', () => { - const singleDynamic = manifest.routes.find(route => route.path === '/dynamic/:id'); + const singleDynamic = manifest.dynamicRoutes.find(route => route.path === '/dynamic/:id'); const regex = new RegExp(singleDynamic?.regex ?? ''); expect(regex.test('/dynamic/123')).toBe(true); expect(regex.test('/dynamic/abc')).toBe(true); @@ -45,7 +44,7 @@ describe('dynamic', () => { }); test('should generate correct pattern for mixed static-dynamic route', () => { - const mixedRoute = manifest.routes.find(route => route.path === '/users/:id/settings'); + const mixedRoute = manifest.dynamicRoutes.find(route => route.path === '/users/:id/settings'); const regex = new RegExp(mixedRoute?.regex ?? ''); expect(regex.test('/users/123/settings')).toBe(true); @@ -56,7 +55,7 @@ describe('dynamic', () => { }); test('should generate correct pattern for multiple dynamic segments', () => { - const multiDynamic = manifest.routes.find(route => route.path === '/users/:id/posts/:postId'); + const multiDynamic = manifest.dynamicRoutes.find(route => route.path === '/users/:id/posts/:postId'); const regex = new RegExp(multiDynamic?.regex ?? ''); expect(regex.test('/users/123/posts/456')).toBe(true); @@ -72,8 +71,7 @@ describe('dynamic', () => { }); test('should handle special characters in dynamic segments', () => { - // Test that dynamic segments with special characters work properly - const userSettingsRoute = manifest.routes.find(route => route.path === '/users/:id/settings'); + const userSettingsRoute = manifest.dynamicRoutes.find(route => route.path === '/users/:id/settings'); expect(userSettingsRoute).toBeDefined(); expect(userSettingsRoute?.regex).toBeDefined(); diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts index b646e7fe994d..2c898b1e8e96 100644 --- a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts @@ -7,7 +7,7 @@ describe('file-extensions', () => { test('should detect page files with all supported extensions', () => { expect(manifest).toEqual({ - routes: [ + staticRoutes: [ { path: '/' }, { path: '/javascript' }, { path: '/jsx-route' }, @@ -15,6 +15,7 @@ describe('file-extensions', () => { { path: '/precedence' }, { path: '/typescript' }, ], + dynamicRoutes: [], }); }); }); diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts index 7caa9621d342..74f12514469e 100644 --- a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -7,24 +7,26 @@ describe('route-groups', () => { test('should generate a manifest with route groups', () => { expect(manifest).toEqual({ - routes: [ + staticRoutes: [ { path: '/' }, { path: '/login' }, { path: '/signup' }, { path: '/dashboard' }, + { path: '/settings/profile' }, + { path: '/public/about' }, + ], + dynamicRoutes: [ { path: '/dashboard/:id', regex: '^/dashboard/([^/]+)$', paramNames: ['id'], }, - { path: '/settings/profile' }, - { path: '/public/about' }, ], }); }); test('should handle dynamic routes within route groups', () => { - const dynamicRoute = manifest.routes.find(route => route.path.includes('/dashboard/:id')); + const dynamicRoute = manifest.dynamicRoutes.find(route => route.path.includes('/dashboard/:id')); const regex = new RegExp(dynamicRoute?.regex ?? ''); expect(regex.test('/dashboard/123')).toBe(true); expect(regex.test('/dashboard/abc')).toBe(true); diff --git a/packages/nextjs/test/config/manifest/suites/static/static.test.ts b/packages/nextjs/test/config/manifest/suites/static/static.test.ts index ca927457ad5d..a6f03f49b6fe 100644 --- a/packages/nextjs/test/config/manifest/suites/static/static.test.ts +++ b/packages/nextjs/test/config/manifest/suites/static/static.test.ts @@ -6,7 +6,8 @@ describe('static', () => { test('should generate a static manifest', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); expect(manifest).toEqual({ - routes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], + staticRoutes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], + dynamicRoutes: [], }); }); }); From e416a2c89afe78ecba33932980020d71d9407a75 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 11 Jul 2025 11:34:55 +0200 Subject: [PATCH 11/11] add option for route-groups --- .../config/manifest/createRouteManifest.ts | 36 ++++-- .../suites/route-groups/route-groups.test.ts | 107 ++++++++++++++---- 2 files changed, 106 insertions(+), 37 deletions(-) diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index 3723fbe77ce2..4df71b389c8b 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -5,10 +5,16 @@ import type { RouteInfo, RouteManifest } from './types'; export type CreateRouteManifestOptions = { // For starters we only support app router appDirPath?: string; + /** + * Whether to include route groups (e.g., (auth-layout)) in the final route paths. + * By default, route groups are stripped from paths following Next.js convention. + */ + includeRouteGroups?: boolean; }; let manifestCache: RouteManifest | null = null; let lastAppDirPath: string | null = null; +let lastIncludeRouteGroups: boolean | undefined = undefined; function isPageFile(filename: string): boolean { return filename === 'page.tsx' || filename === 'page.jsx' || filename === 'page.ts' || filename === 'page.js'; @@ -64,7 +70,7 @@ function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNam regexSegments.push('([^/]+)'); } } else { - // Static segment + // Static segment - escape regex special characters including route group parentheses regexSegments.push(segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); } } @@ -85,6 +91,7 @@ function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNam function scanAppDirectory( dir: string, basePath: string = '', + includeRouteGroups: boolean = false, ): { dynamicRoutes: RouteInfo[]; staticRoutes: RouteInfo[] } { const dynamicRoutes: RouteInfo[] = []; const staticRoutes: RouteInfo[] = []; @@ -94,20 +101,20 @@ function scanAppDirectory( const pageFile = entries.some(entry => isPageFile(entry.name)); if (pageFile) { - // Normalize the path by removing route groups when creating the final route - const normalizedRoutePath = normalizeRoutePath(basePath || '/'); - const isDynamic = normalizedRoutePath.includes(':'); + // Conditionally normalize the path based on includeRouteGroups option + const routePath = includeRouteGroups ? basePath || '/' : normalizeRoutePath(basePath || '/'); + const isDynamic = routePath.includes(':'); if (isDynamic) { - const { regex, paramNames } = buildRegexForDynamicRoute(normalizedRoutePath); + const { regex, paramNames } = buildRegexForDynamicRoute(routePath); dynamicRoutes.push({ - path: normalizedRoutePath, + path: routePath, regex, paramNames, }); } else { staticRoutes.push({ - path: normalizedRoutePath, + path: routePath, }); } } @@ -121,15 +128,19 @@ function scanAppDirectory( const isRouteGroupDir = isRouteGroup(entry.name); if (isRouteGroupDir) { - routeSegment = entry.name; + if (includeRouteGroups) { + routeSegment = entry.name; + } else { + routeSegment = ''; + } } else if (isDynamic) { routeSegment = getDynamicRouteSegment(entry.name); } else { routeSegment = entry.name; } - const newBasePath = `${basePath}/${routeSegment}`; - const subRoutes = scanAppDirectory(fullPath, newBasePath); + const newBasePath = routeSegment ? `${basePath}/${routeSegment}` : basePath; + const subRoutes = scanAppDirectory(fullPath, newBasePath, includeRouteGroups); dynamicRoutes.push(...subRoutes.dynamicRoutes); staticRoutes.push(...subRoutes.staticRoutes); @@ -171,11 +182,11 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route } // Check if we can use cached version - if (manifestCache && lastAppDirPath === targetDir) { + if (manifestCache && lastAppDirPath === targetDir && lastIncludeRouteGroups === options?.includeRouteGroups) { return manifestCache; } - const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir); + const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir, '', options?.includeRouteGroups); const manifest: RouteManifest = { dynamicRoutes, @@ -185,6 +196,7 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route // set cache manifestCache = manifest; lastAppDirPath = targetDir; + lastIncludeRouteGroups = options?.includeRouteGroups; return manifest; } diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts index 74f12514469e..36ac9077df7e 100644 --- a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -3,33 +3,90 @@ import { describe, expect, test } from 'vitest'; import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; describe('route-groups', () => { - const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); - - test('should generate a manifest with route groups', () => { - expect(manifest).toEqual({ - staticRoutes: [ - { path: '/' }, - { path: '/login' }, - { path: '/signup' }, - { path: '/dashboard' }, - { path: '/settings/profile' }, - { path: '/public/about' }, - ], - dynamicRoutes: [ - { - path: '/dashboard/:id', - regex: '^/dashboard/([^/]+)$', - paramNames: ['id'], - }, - ], + const appDirPath = path.join(__dirname, 'app'); + + describe('default behavior (route groups stripped)', () => { + const manifest = createRouteManifest({ appDirPath }); + + test('should generate a manifest with route groups stripped', () => { + expect(manifest).toEqual({ + staticRoutes: [ + { path: '/' }, + { path: '/login' }, + { path: '/signup' }, + { path: '/dashboard' }, + { path: '/settings/profile' }, + { path: '/public/about' }, + ], + dynamicRoutes: [ + { + path: '/dashboard/:id', + regex: '^/dashboard/([^/]+)$', + paramNames: ['id'], + }, + ], + }); + }); + + test('should handle dynamic routes within route groups', () => { + const dynamicRoute = manifest.dynamicRoutes.find(route => route.path.includes('/dashboard/:id')); + const regex = new RegExp(dynamicRoute?.regex ?? ''); + expect(regex.test('/dashboard/123')).toBe(true); + expect(regex.test('/dashboard/abc')).toBe(true); + expect(regex.test('/dashboard/123/456')).toBe(false); }); }); - test('should handle dynamic routes within route groups', () => { - const dynamicRoute = manifest.dynamicRoutes.find(route => route.path.includes('/dashboard/:id')); - const regex = new RegExp(dynamicRoute?.regex ?? ''); - expect(regex.test('/dashboard/123')).toBe(true); - expect(regex.test('/dashboard/abc')).toBe(true); - expect(regex.test('/dashboard/123/456')).toBe(false); + describe('includeRouteGroups: true', () => { + const manifest = createRouteManifest({ appDirPath, includeRouteGroups: true }); + + test('should generate a manifest with route groups included', () => { + expect(manifest).toEqual({ + staticRoutes: [ + { path: '/' }, + { path: '/(auth)/login' }, + { path: '/(auth)/signup' }, + { path: '/(dashboard)/dashboard' }, + { path: '/(dashboard)/settings/profile' }, + { path: '/(marketing)/public/about' }, + ], + dynamicRoutes: [ + { + path: '/(dashboard)/dashboard/:id', + regex: '^/\\(dashboard\\)/dashboard/([^/]+)$', + paramNames: ['id'], + }, + ], + }); + }); + + test('should handle dynamic routes within route groups with proper regex escaping', () => { + const dynamicRoute = manifest.dynamicRoutes.find(route => route.path.includes('/(dashboard)/dashboard/:id')); + const regex = new RegExp(dynamicRoute?.regex ?? ''); + expect(regex.test('/(dashboard)/dashboard/123')).toBe(true); + expect(regex.test('/(dashboard)/dashboard/abc')).toBe(true); + expect(regex.test('/(dashboard)/dashboard/123/456')).toBe(false); + expect(regex.test('/dashboard/123')).toBe(false); // Should not match without route group + }); + + test('should properly extract parameter names from dynamic routes with route groups', () => { + const dynamicRoute = manifest.dynamicRoutes.find(route => route.path.includes('/(dashboard)/dashboard/:id')); + expect(dynamicRoute?.paramNames).toEqual(['id']); + }); + + test('should handle nested static routes within route groups', () => { + const nestedStaticRoute = manifest.staticRoutes.find(route => route.path === '/(dashboard)/settings/profile'); + expect(nestedStaticRoute).toBeDefined(); + }); + + test('should handle multiple route groups correctly', () => { + const authLogin = manifest.staticRoutes.find(route => route.path === '/(auth)/login'); + const authSignup = manifest.staticRoutes.find(route => route.path === '/(auth)/signup'); + const marketingPublic = manifest.staticRoutes.find(route => route.path === '/(marketing)/public/about'); + + expect(authLogin).toBeDefined(); + expect(authSignup).toBeDefined(); + expect(marketingPublic).toBeDefined(); + }); }); });