diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts new file mode 100644 index 000000000000..4df71b389c8b --- /dev/null +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -0,0 +1,202 @@ +import * as fs from 'fs'; +import * as path from 'path'; +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'; +} + +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]] + 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}*`; + } + // Regular dynamic: [param] + return `:${name.slice(1, -1)}`; +} + +function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNames: string[] } { + const segments = routePath.split('/').filter(Boolean); + const regexSegments: string[] = []; + const paramNames: string[] = []; + let hasOptionalCatchall = false; + + 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); + // 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); + paramNames.push(cleanParamName); + regexSegments.push('(.+)'); + } else { + // Regular dynamic segment + paramNames.push(paramName); + regexSegments.push('([^/]+)'); + } + } else { + // Static segment - escape regex special characters including route group parentheses + regexSegments.push(segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + } + } + + 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 { regex: pattern, paramNames }; +} + +function scanAppDirectory( + dir: string, + basePath: string = '', + includeRouteGroups: boolean = false, +): { 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) { + // Conditionally normalize the path based on includeRouteGroups option + const routePath = includeRouteGroups ? basePath || '/' : normalizeRoutePath(basePath || '/'); + const isDynamic = routePath.includes(':'); + + if (isDynamic) { + const { regex, paramNames } = buildRegexForDynamicRoute(routePath); + dynamicRoutes.push({ + path: routePath, + regex, + paramNames, + }); + } else { + staticRoutes.push({ + path: routePath, + }); + } + } + + for (const entry of entries) { + if (entry.isDirectory()) { + const fullPath = path.join(dir, entry.name); + let routeSegment: string; + + const isDynamic = entry.name.startsWith('[') && entry.name.endsWith(']'); + const isRouteGroupDir = isRouteGroup(entry.name); + + if (isRouteGroupDir) { + if (includeRouteGroups) { + routeSegment = entry.name; + } else { + routeSegment = ''; + } + } else if (isDynamic) { + routeSegment = getDynamicRouteSegment(entry.name); + } else { + routeSegment = entry.name; + } + + const newBasePath = routeSegment ? `${basePath}/${routeSegment}` : basePath; + const subRoutes = scanAppDirectory(fullPath, newBasePath, includeRouteGroups); + + dynamicRoutes.push(...subRoutes.dynamicRoutes); + staticRoutes.push(...subRoutes.staticRoutes); + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.warn('Error building route manifest:', error); + } + + return { dynamicRoutes, staticRoutes }; +} + +/** + * 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 { + dynamicRoutes: [], + staticRoutes: [], + }; + } + + // Check if we can use cached version + if (manifestCache && lastAppDirPath === targetDir && lastIncludeRouteGroups === options?.includeRouteGroups) { + return manifestCache; + } + + const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir, '', options?.includeRouteGroups); + + const manifest: RouteManifest = { + dynamicRoutes, + staticRoutes, + }; + + // set cache + manifestCache = manifest; + lastAppDirPath = targetDir; + lastIncludeRouteGroups = options?.includeRouteGroups; + + return manifest; +} diff --git a/packages/nextjs/src/config/manifest/types.ts b/packages/nextjs/src/config/manifest/types.ts new file mode 100644 index 000000000000..e3a26adfce2f --- /dev/null +++ b/packages/nextjs/src/config/manifest/types.ts @@ -0,0 +1,32 @@ +/** + * 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 dynamic routes + */ + dynamicRoutes: RouteInfo[]; + + /** + * List of all static routes + */ + staticRoutes: RouteInfo[]; +}; 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..b1c417970ba4 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -0,0 +1,33 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +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({ + staticRoutes: [{ path: '/' }], + dynamicRoutes: [ + { + path: '/catchall/:path*?', + regex: '^/catchall(?:/(.*))?$', + paramNames: ['path'], + }, + ], + }); + }); + + test('should generate correct pattern for catchall route', () => { + 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); + 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); + }); +}); 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/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/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..fdcae299d7cf --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -0,0 +1,84 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; + +describe('dynamic', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + + test('should generate a dynamic manifest', () => { + expect(manifest).toEqual({ + staticRoutes: [{ path: '/' }, { path: '/dynamic/static' }, { path: '/static/nested' }], + dynamicRoutes: [ + { + path: '/dynamic/:id', + regex: '^/dynamic/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id/posts/:postId', + regex: '^/users/([^/]+)/posts/([^/]+)$', + paramNames: ['id', 'postId'], + }, + { + path: '/users/:id/settings', + regex: '^/users/([^/]+)/settings$', + paramNames: ['id'], + }, + ], + }); + }); + + test('should generate correct pattern for single dynamic route', () => { + 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); + 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.dynamicRoutes.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); + 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.dynamicRoutes.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); + 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', () => { + const userSettingsRoute = manifest.dynamicRoutes.find(route => route.path === '/users/:id/settings'); + expect(userSettingsRoute).toBeDefined(); + expect(userSettingsRoute?.regex).toBeDefined(); + + 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); + 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..2c898b1e8e96 --- /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/createRouteManifest'; + +describe('file-extensions', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + + test('should detect page files with all supported extensions', () => { + expect(manifest).toEqual({ + staticRoutes: [ + { path: '/' }, + { path: '/javascript' }, + { path: '/jsx-route' }, + { path: '/mixed' }, + { path: '/precedence' }, + { path: '/typescript' }, + ], + dynamicRoutes: [], + }); + }); +}); 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..36ac9077df7e --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -0,0 +1,92 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; + +describe('route-groups', () => { + 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); + }); + }); + + 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(); + }); + }); +}); 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..a6f03f49b6fe --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/static/static.test.ts @@ -0,0 +1,13 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +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({ + staticRoutes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], + dynamicRoutes: [], + }); + }); +});