Skip to content

Commit 7a0232d

Browse files
authored
feat(nextjs): Build app manifest (#16851)
1 parent 422443e commit 7a0232d

File tree

43 files changed

+513
-0
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+513
-0
lines changed
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import type { RouteInfo, RouteManifest } from './types';
4+
5+
export type CreateRouteManifestOptions = {
6+
// For starters we only support app router
7+
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;
13+
};
14+
15+
let manifestCache: RouteManifest | null = null;
16+
let lastAppDirPath: string | null = null;
17+
let lastIncludeRouteGroups: boolean | undefined = undefined;
18+
19+
function isPageFile(filename: string): boolean {
20+
return filename === 'page.tsx' || filename === 'page.jsx' || filename === 'page.ts' || filename === 'page.js';
21+
}
22+
23+
function isRouteGroup(name: string): boolean {
24+
return name.startsWith('(') && name.endsWith(')');
25+
}
26+
27+
function normalizeRoutePath(routePath: string): string {
28+
// Remove route group segments from the path
29+
return routePath.replace(/\/\([^)]+\)/g, '');
30+
}
31+
32+
function getDynamicRouteSegment(name: string): string {
33+
if (name.startsWith('[[...') && name.endsWith(']]')) {
34+
// Optional catchall: [[...param]]
35+
const paramName = name.slice(5, -2); // Remove [[... and ]]
36+
return `:${paramName}*?`; // Mark with ? as optional
37+
} else if (name.startsWith('[...') && name.endsWith(']')) {
38+
// Required catchall: [...param]
39+
const paramName = name.slice(4, -1); // Remove [... and ]
40+
return `:${paramName}*`;
41+
}
42+
// Regular dynamic: [param]
43+
return `:${name.slice(1, -1)}`;
44+
}
45+
46+
function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNames: string[] } {
47+
const segments = routePath.split('/').filter(Boolean);
48+
const regexSegments: string[] = [];
49+
const paramNames: string[] = [];
50+
let hasOptionalCatchall = false;
51+
52+
for (const segment of segments) {
53+
if (segment.startsWith(':')) {
54+
const paramName = segment.substring(1);
55+
56+
if (paramName.endsWith('*?')) {
57+
// Optional catchall: matches zero or more segments
58+
const cleanParamName = paramName.slice(0, -2);
59+
paramNames.push(cleanParamName);
60+
// Handling this special case in pattern construction below
61+
hasOptionalCatchall = true;
62+
} else if (paramName.endsWith('*')) {
63+
// Required catchall: matches one or more segments
64+
const cleanParamName = paramName.slice(0, -1);
65+
paramNames.push(cleanParamName);
66+
regexSegments.push('(.+)');
67+
} else {
68+
// Regular dynamic segment
69+
paramNames.push(paramName);
70+
regexSegments.push('([^/]+)');
71+
}
72+
} else {
73+
// Static segment - escape regex special characters including route group parentheses
74+
regexSegments.push(segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
75+
}
76+
}
77+
78+
let pattern: string;
79+
if (hasOptionalCatchall) {
80+
// For optional catchall, make the trailing slash and segments optional
81+
// This allows matching both /catchall and /catchall/anything
82+
const staticParts = regexSegments.join('/');
83+
pattern = `^/${staticParts}(?:/(.*))?$`;
84+
} else {
85+
pattern = `^/${regexSegments.join('/')}$`;
86+
}
87+
88+
return { regex: pattern, paramNames };
89+
}
90+
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[] = [];
98+
99+
try {
100+
const entries = fs.readdirSync(dir, { withFileTypes: true });
101+
const pageFile = entries.some(entry => isPageFile(entry.name));
102+
103+
if (pageFile) {
104+
// Conditionally normalize the path based on includeRouteGroups option
105+
const routePath = includeRouteGroups ? basePath || '/' : normalizeRoutePath(basePath || '/');
106+
const isDynamic = routePath.includes(':');
107+
108+
if (isDynamic) {
109+
const { regex, paramNames } = buildRegexForDynamicRoute(routePath);
110+
dynamicRoutes.push({
111+
path: routePath,
112+
regex,
113+
paramNames,
114+
});
115+
} else {
116+
staticRoutes.push({
117+
path: routePath,
118+
});
119+
}
120+
}
121+
122+
for (const entry of entries) {
123+
if (entry.isDirectory()) {
124+
const fullPath = path.join(dir, entry.name);
125+
let routeSegment: string;
126+
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) {
137+
routeSegment = getDynamicRouteSegment(entry.name);
138+
} else {
139+
routeSegment = entry.name;
140+
}
141+
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);
147+
}
148+
}
149+
} catch (error) {
150+
// eslint-disable-next-line no-console
151+
console.warn('Error building route manifest:', error);
152+
}
153+
154+
return { dynamicRoutes, staticRoutes };
155+
}
156+
157+
/**
158+
* Returns a route manifest for the given app directory
159+
*/
160+
export function createRouteManifest(options?: CreateRouteManifestOptions): RouteManifest {
161+
let targetDir: string | undefined;
162+
163+
if (options?.appDirPath) {
164+
targetDir = options.appDirPath;
165+
} else {
166+
const projectDir = process.cwd();
167+
const maybeAppDirPath = path.join(projectDir, 'app');
168+
const maybeSrcAppDirPath = path.join(projectDir, 'src', 'app');
169+
170+
if (fs.existsSync(maybeAppDirPath) && fs.lstatSync(maybeAppDirPath).isDirectory()) {
171+
targetDir = maybeAppDirPath;
172+
} else if (fs.existsSync(maybeSrcAppDirPath) && fs.lstatSync(maybeSrcAppDirPath).isDirectory()) {
173+
targetDir = maybeSrcAppDirPath;
174+
}
175+
}
176+
177+
if (!targetDir) {
178+
return {
179+
dynamicRoutes: [],
180+
staticRoutes: [],
181+
};
182+
}
183+
184+
// Check if we can use cached version
185+
if (manifestCache && lastAppDirPath === targetDir && lastIncludeRouteGroups === options?.includeRouteGroups) {
186+
return manifestCache;
187+
}
188+
189+
const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir, '', options?.includeRouteGroups);
190+
191+
const manifest: RouteManifest = {
192+
dynamicRoutes,
193+
staticRoutes,
194+
};
195+
196+
// set cache
197+
manifestCache = manifest;
198+
lastAppDirPath = targetDir;
199+
lastIncludeRouteGroups = options?.includeRouteGroups;
200+
201+
return manifest;
202+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Information about a single route in the manifest
3+
*/
4+
export type RouteInfo = {
5+
/**
6+
* The parameterised route path, e.g. "/users/[id]"
7+
*/
8+
path: string;
9+
/**
10+
* (Optional) The regex pattern for dynamic routes
11+
*/
12+
regex?: string;
13+
/**
14+
* (Optional) The names of dynamic parameters in the route
15+
*/
16+
paramNames?: string[];
17+
};
18+
19+
/**
20+
* The manifest containing all routes discovered in the app
21+
*/
22+
export type RouteManifest = {
23+
/**
24+
* List of all dynamic routes
25+
*/
26+
dynamicRoutes: RouteInfo[];
27+
28+
/**
29+
* List of all static routes
30+
*/
31+
staticRoutes: RouteInfo[];
32+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// beep
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// Ciao
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import path from 'path';
2+
import { describe, expect, test } from 'vitest';
3+
import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest';
4+
5+
describe('catchall', () => {
6+
const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') });
7+
8+
test('should generate a manifest with catchall route', () => {
9+
expect(manifest).toEqual({
10+
staticRoutes: [{ path: '/' }],
11+
dynamicRoutes: [
12+
{
13+
path: '/catchall/:path*?',
14+
regex: '^/catchall(?:/(.*))?$',
15+
paramNames: ['path'],
16+
},
17+
],
18+
});
19+
});
20+
21+
test('should generate correct pattern for catchall route', () => {
22+
const catchallRoute = manifest.dynamicRoutes.find(route => route.path === '/catchall/:path*?');
23+
const regex = new RegExp(catchallRoute?.regex ?? '');
24+
expect(regex.test('/catchall/123')).toBe(true);
25+
expect(regex.test('/catchall/abc')).toBe(true);
26+
expect(regex.test('/catchall/123/456')).toBe(true);
27+
expect(regex.test('/catchall/123/abc/789')).toBe(true);
28+
expect(regex.test('/catchall/')).toBe(true);
29+
expect(regex.test('/catchall')).toBe(true);
30+
expect(regex.test('/123/catchall/123')).toBe(false);
31+
expect(regex.test('/')).toBe(false);
32+
});
33+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// beep
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// Static
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// Ciao
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// Hola
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// User profile page

0 commit comments

Comments
 (0)