Skip to content

feat(nextjs): Build app manifest #16851

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jul 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 202 additions & 0 deletions packages/nextjs/src/config/manifest/createRouteManifest.ts
Original file line number Diff line number Diff line change
@@ -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;
}
32 changes: 32 additions & 0 deletions packages/nextjs/src/config/manifest/types.ts
Original file line number Diff line number Diff line change
@@ -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[];
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// beep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Ciao
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// beep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Static
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Ciao
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Hola
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// User profile page
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Post detail page
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// User settings page
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Component file - should be ignored
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// JavaScript page
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// JSX page
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Layout file - should be ignored
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// JSX page
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// TypeScript page
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Root page - TypeScript JSX
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// JavaScript page - should be ignored if tsx exists
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// TypeScript JSX page - should take precedence
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Other TypeScript file - should be ignored
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// TypeScript page
Loading
Loading