Skip to content

Commit 78ff933

Browse files
authored
Merge branch 'cg-next-client-manifest' into cg-next-manifest-webpack
2 parents f9430fe + e416a2c commit 78ff933

File tree

8 files changed

+149
-62
lines changed

8 files changed

+149
-62
lines changed

packages/nextjs/src/config/manifest/createRouteManifest.ts

Lines changed: 46 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,16 @@ import type { RouteInfo, RouteManifest } from './types';
55
export type CreateRouteManifestOptions = {
66
// For starters we only support app router
77
appDirPath?: string;
8+
/**
9+
* Whether to include route groups (e.g., (auth-layout)) in the final route paths.
10+
* By default, route groups are stripped from paths following Next.js convention.
11+
*/
12+
includeRouteGroups?: boolean;
813
};
914

1015
let manifestCache: RouteManifest | null = null;
1116
let lastAppDirPath: string | null = null;
17+
let lastIncludeRouteGroups: boolean | undefined = undefined;
1218

1319
function isPageFile(filename: string): boolean {
1420
return filename === 'page.tsx' || filename === 'page.jsx' || filename === 'page.ts' || filename === 'page.js';
@@ -18,6 +24,11 @@ function isRouteGroup(name: string): boolean {
1824
return name.startsWith('(') && name.endsWith(')');
1925
}
2026

27+
function normalizeRoutePath(routePath: string): string {
28+
// Remove route group segments from the path
29+
return routePath.replace(/\/\([^)]+\)/g, '');
30+
}
31+
2132
function getDynamicRouteSegment(name: string): string {
2233
if (name.startsWith('[[...') && name.endsWith(']]')) {
2334
// Optional catchall: [[...param]]
@@ -59,7 +70,7 @@ function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNam
5970
regexSegments.push('([^/]+)');
6071
}
6172
} else {
62-
// Static segment
73+
// Static segment - escape regex special characters including route group parentheses
6374
regexSegments.push(segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
6475
}
6576
}
@@ -77,26 +88,32 @@ function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNam
7788
return { regex: pattern, paramNames };
7889
}
7990

80-
function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] {
81-
const routes: RouteInfo[] = [];
91+
function scanAppDirectory(
92+
dir: string,
93+
basePath: string = '',
94+
includeRouteGroups: boolean = false,
95+
): { dynamicRoutes: RouteInfo[]; staticRoutes: RouteInfo[] } {
96+
const dynamicRoutes: RouteInfo[] = [];
97+
const staticRoutes: RouteInfo[] = [];
8298

8399
try {
84100
const entries = fs.readdirSync(dir, { withFileTypes: true });
85101
const pageFile = entries.some(entry => isPageFile(entry.name));
86102

87103
if (pageFile) {
88-
const routePath = basePath || '/';
104+
// Conditionally normalize the path based on includeRouteGroups option
105+
const routePath = includeRouteGroups ? basePath || '/' : normalizeRoutePath(basePath || '/');
89106
const isDynamic = routePath.includes(':');
90107

91108
if (isDynamic) {
92109
const { regex, paramNames } = buildRegexForDynamicRoute(routePath);
93-
routes.push({
110+
dynamicRoutes.push({
94111
path: routePath,
95112
regex,
96113
paramNames,
97114
});
98115
} else {
99-
routes.push({
116+
staticRoutes.push({
100117
path: routePath,
101118
});
102119
}
@@ -105,34 +122,36 @@ function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] {
105122
for (const entry of entries) {
106123
if (entry.isDirectory()) {
107124
const fullPath = path.join(dir, entry.name);
108-
109-
if (isRouteGroup(entry.name)) {
110-
// Route groups don't affect the URL, just scan them
111-
const subRoutes = scanAppDirectory(fullPath, basePath);
112-
routes.push(...subRoutes);
113-
continue;
114-
}
115-
116-
const isDynamic = entry.name.startsWith('[') && entry.name.endsWith(']');
117125
let routeSegment: string;
118126

119-
if (isDynamic) {
127+
const isDynamic = entry.name.startsWith('[') && entry.name.endsWith(']');
128+
const isRouteGroupDir = isRouteGroup(entry.name);
129+
130+
if (isRouteGroupDir) {
131+
if (includeRouteGroups) {
132+
routeSegment = entry.name;
133+
} else {
134+
routeSegment = '';
135+
}
136+
} else if (isDynamic) {
120137
routeSegment = getDynamicRouteSegment(entry.name);
121138
} else {
122139
routeSegment = entry.name;
123140
}
124141

125-
const newBasePath = `${basePath}/${routeSegment}`;
126-
const subRoutes = scanAppDirectory(fullPath, newBasePath);
127-
routes.push(...subRoutes);
142+
const newBasePath = routeSegment ? `${basePath}/${routeSegment}` : basePath;
143+
const subRoutes = scanAppDirectory(fullPath, newBasePath, includeRouteGroups);
144+
145+
dynamicRoutes.push(...subRoutes.dynamicRoutes);
146+
staticRoutes.push(...subRoutes.staticRoutes);
128147
}
129148
}
130149
} catch (error) {
131150
// eslint-disable-next-line no-console
132151
console.warn('Error building route manifest:', error);
133152
}
134153

135-
return routes;
154+
return { dynamicRoutes, staticRoutes };
136155
}
137156

138157
/**
@@ -157,24 +176,27 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route
157176

158177
if (!targetDir) {
159178
return {
160-
routes: [],
179+
dynamicRoutes: [],
180+
staticRoutes: [],
161181
};
162182
}
163183

164184
// Check if we can use cached version
165-
if (manifestCache && lastAppDirPath === targetDir) {
185+
if (manifestCache && lastAppDirPath === targetDir && lastIncludeRouteGroups === options?.includeRouteGroups) {
166186
return manifestCache;
167187
}
168188

169-
const routes = scanAppDirectory(targetDir);
189+
const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir, '', options?.includeRouteGroups);
170190

171191
const manifest: RouteManifest = {
172-
routes,
192+
dynamicRoutes,
193+
staticRoutes,
173194
};
174195

175196
// set cache
176197
manifestCache = manifest;
177198
lastAppDirPath = targetDir;
199+
lastIncludeRouteGroups = options?.includeRouteGroups;
178200

179201
return manifest;
180202
}

packages/nextjs/src/config/manifest/types.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ export type RouteInfo = {
2121
*/
2222
export type RouteManifest = {
2323
/**
24-
* List of all routes (static and dynamic)
24+
* List of all dynamic routes
2525
*/
26-
routes: RouteInfo[];
26+
dynamicRoutes: RouteInfo[];
27+
28+
/**
29+
* List of all static routes
30+
*/
31+
staticRoutes: RouteInfo[];
2732
};

packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ describe('catchall', () => {
77

88
test('should generate a manifest with catchall route', () => {
99
expect(manifest).toEqual({
10-
routes: [
11-
{ path: '/' },
10+
staticRoutes: [{ path: '/' }],
11+
dynamicRoutes: [
1212
{
1313
path: '/catchall/:path*?',
1414
regex: '^/catchall(?:/(.*))?$',
@@ -19,7 +19,7 @@ describe('catchall', () => {
1919
});
2020

2121
test('should generate correct pattern for catchall route', () => {
22-
const catchallRoute = manifest.routes.find(route => route.path === '/catchall/:path*?');
22+
const catchallRoute = manifest.dynamicRoutes.find(route => route.path === '/catchall/:path*?');
2323
const regex = new RegExp(catchallRoute?.regex ?? '');
2424
expect(regex.test('/catchall/123')).toBe(true);
2525
expect(regex.test('/catchall/abc')).toBe(true);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// Static

packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,13 @@ describe('dynamic', () => {
77

88
test('should generate a dynamic manifest', () => {
99
expect(manifest).toEqual({
10-
routes: [
11-
{ path: '/' },
10+
staticRoutes: [{ path: '/' }, { path: '/dynamic/static' }, { path: '/static/nested' }],
11+
dynamicRoutes: [
1212
{
1313
path: '/dynamic/:id',
1414
regex: '^/dynamic/([^/]+)$',
1515
paramNames: ['id'],
1616
},
17-
{ path: '/static/nested' },
1817
{
1918
path: '/users/:id',
2019
regex: '^/users/([^/]+)$',
@@ -35,7 +34,7 @@ describe('dynamic', () => {
3534
});
3635

3736
test('should generate correct pattern for single dynamic route', () => {
38-
const singleDynamic = manifest.routes.find(route => route.path === '/dynamic/:id');
37+
const singleDynamic = manifest.dynamicRoutes.find(route => route.path === '/dynamic/:id');
3938
const regex = new RegExp(singleDynamic?.regex ?? '');
4039
expect(regex.test('/dynamic/123')).toBe(true);
4140
expect(regex.test('/dynamic/abc')).toBe(true);
@@ -45,7 +44,7 @@ describe('dynamic', () => {
4544
});
4645

4746
test('should generate correct pattern for mixed static-dynamic route', () => {
48-
const mixedRoute = manifest.routes.find(route => route.path === '/users/:id/settings');
47+
const mixedRoute = manifest.dynamicRoutes.find(route => route.path === '/users/:id/settings');
4948
const regex = new RegExp(mixedRoute?.regex ?? '');
5049

5150
expect(regex.test('/users/123/settings')).toBe(true);
@@ -56,7 +55,7 @@ describe('dynamic', () => {
5655
});
5756

5857
test('should generate correct pattern for multiple dynamic segments', () => {
59-
const multiDynamic = manifest.routes.find(route => route.path === '/users/:id/posts/:postId');
58+
const multiDynamic = manifest.dynamicRoutes.find(route => route.path === '/users/:id/posts/:postId');
6059
const regex = new RegExp(multiDynamic?.regex ?? '');
6160

6261
expect(regex.test('/users/123/posts/456')).toBe(true);
@@ -72,8 +71,7 @@ describe('dynamic', () => {
7271
});
7372

7473
test('should handle special characters in dynamic segments', () => {
75-
// Test that dynamic segments with special characters work properly
76-
const userSettingsRoute = manifest.routes.find(route => route.path === '/users/:id/settings');
74+
const userSettingsRoute = manifest.dynamicRoutes.find(route => route.path === '/users/:id/settings');
7775
expect(userSettingsRoute).toBeDefined();
7876
expect(userSettingsRoute?.regex).toBeDefined();
7977

packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@ describe('file-extensions', () => {
77

88
test('should detect page files with all supported extensions', () => {
99
expect(manifest).toEqual({
10-
routes: [
10+
staticRoutes: [
1111
{ path: '/' },
1212
{ path: '/javascript' },
1313
{ path: '/jsx-route' },
1414
{ path: '/mixed' },
1515
{ path: '/precedence' },
1616
{ path: '/typescript' },
1717
],
18+
dynamicRoutes: [],
1819
});
1920
});
2021
});

packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts

Lines changed: 82 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,90 @@ import { describe, expect, test } from 'vitest';
33
import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest';
44

55
describe('route-groups', () => {
6-
const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') });
7-
8-
test('should generate a manifest with route groups', () => {
9-
expect(manifest).toEqual({
10-
routes: [
11-
{ path: '/' },
12-
{ path: '/login' },
13-
{ path: '/signup' },
14-
{ path: '/dashboard' },
15-
{
16-
path: '/dashboard/:id',
17-
regex: '^/dashboard/([^/]+)$',
18-
paramNames: ['id'],
19-
},
20-
{ path: '/settings/profile' },
21-
{ path: '/public/about' },
22-
],
6+
const appDirPath = path.join(__dirname, 'app');
7+
8+
describe('default behavior (route groups stripped)', () => {
9+
const manifest = createRouteManifest({ appDirPath });
10+
11+
test('should generate a manifest with route groups stripped', () => {
12+
expect(manifest).toEqual({
13+
staticRoutes: [
14+
{ path: '/' },
15+
{ path: '/login' },
16+
{ path: '/signup' },
17+
{ path: '/dashboard' },
18+
{ path: '/settings/profile' },
19+
{ path: '/public/about' },
20+
],
21+
dynamicRoutes: [
22+
{
23+
path: '/dashboard/:id',
24+
regex: '^/dashboard/([^/]+)$',
25+
paramNames: ['id'],
26+
},
27+
],
28+
});
29+
});
30+
31+
test('should handle dynamic routes within route groups', () => {
32+
const dynamicRoute = manifest.dynamicRoutes.find(route => route.path.includes('/dashboard/:id'));
33+
const regex = new RegExp(dynamicRoute?.regex ?? '');
34+
expect(regex.test('/dashboard/123')).toBe(true);
35+
expect(regex.test('/dashboard/abc')).toBe(true);
36+
expect(regex.test('/dashboard/123/456')).toBe(false);
2337
});
2438
});
2539

26-
test('should handle dynamic routes within route groups', () => {
27-
const dynamicRoute = manifest.routes.find(route => route.path.includes('/dashboard/:id'));
28-
const regex = new RegExp(dynamicRoute?.regex ?? '');
29-
expect(regex.test('/dashboard/123')).toBe(true);
30-
expect(regex.test('/dashboard/abc')).toBe(true);
31-
expect(regex.test('/dashboard/123/456')).toBe(false);
40+
describe('includeRouteGroups: true', () => {
41+
const manifest = createRouteManifest({ appDirPath, includeRouteGroups: true });
42+
43+
test('should generate a manifest with route groups included', () => {
44+
expect(manifest).toEqual({
45+
staticRoutes: [
46+
{ path: '/' },
47+
{ path: '/(auth)/login' },
48+
{ path: '/(auth)/signup' },
49+
{ path: '/(dashboard)/dashboard' },
50+
{ path: '/(dashboard)/settings/profile' },
51+
{ path: '/(marketing)/public/about' },
52+
],
53+
dynamicRoutes: [
54+
{
55+
path: '/(dashboard)/dashboard/:id',
56+
regex: '^/\\(dashboard\\)/dashboard/([^/]+)$',
57+
paramNames: ['id'],
58+
},
59+
],
60+
});
61+
});
62+
63+
test('should handle dynamic routes within route groups with proper regex escaping', () => {
64+
const dynamicRoute = manifest.dynamicRoutes.find(route => route.path.includes('/(dashboard)/dashboard/:id'));
65+
const regex = new RegExp(dynamicRoute?.regex ?? '');
66+
expect(regex.test('/(dashboard)/dashboard/123')).toBe(true);
67+
expect(regex.test('/(dashboard)/dashboard/abc')).toBe(true);
68+
expect(regex.test('/(dashboard)/dashboard/123/456')).toBe(false);
69+
expect(regex.test('/dashboard/123')).toBe(false); // Should not match without route group
70+
});
71+
72+
test('should properly extract parameter names from dynamic routes with route groups', () => {
73+
const dynamicRoute = manifest.dynamicRoutes.find(route => route.path.includes('/(dashboard)/dashboard/:id'));
74+
expect(dynamicRoute?.paramNames).toEqual(['id']);
75+
});
76+
77+
test('should handle nested static routes within route groups', () => {
78+
const nestedStaticRoute = manifest.staticRoutes.find(route => route.path === '/(dashboard)/settings/profile');
79+
expect(nestedStaticRoute).toBeDefined();
80+
});
81+
82+
test('should handle multiple route groups correctly', () => {
83+
const authLogin = manifest.staticRoutes.find(route => route.path === '/(auth)/login');
84+
const authSignup = manifest.staticRoutes.find(route => route.path === '/(auth)/signup');
85+
const marketingPublic = manifest.staticRoutes.find(route => route.path === '/(marketing)/public/about');
86+
87+
expect(authLogin).toBeDefined();
88+
expect(authSignup).toBeDefined();
89+
expect(marketingPublic).toBeDefined();
90+
});
3291
});
3392
});

packages/nextjs/test/config/manifest/suites/static/static.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ describe('static', () => {
66
test('should generate a static manifest', () => {
77
const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') });
88
expect(manifest).toEqual({
9-
routes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }],
9+
staticRoutes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }],
10+
dynamicRoutes: [],
1011
});
1112
});
1213
});

0 commit comments

Comments
 (0)