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 10 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
180 changes: 180 additions & 0 deletions packages/nextjs/src/config/manifest/createRouteManifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
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;
};

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}*`;
}
// 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
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 = ''): RouteInfo[] {
const routes: 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(':');

if (isDynamic) {
const { regex, paramNames } = buildRegexForDynamicRoute(routePath);
routes.push({
path: routePath,
regex,
paramNames,
});
} else {
routes.push({
path: routePath,
});
}
}

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;
}

/**
* 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 {
routes: [],
};
}

// Check if we can use cached version
if (manifestCache && lastAppDirPath === targetDir) {
return manifestCache;
}

const routes = scanAppDirectory(targetDir);

const manifest: RouteManifest = {
routes,
};

// set cache
manifestCache = manifest;
lastAppDirPath = targetDir;

return manifest;
}
27 changes: 27 additions & 0 deletions packages/nextjs/src/config/manifest/types.ts
Original file line number Diff line number Diff line change
@@ -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[];
};
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({
routes: [
{ path: '/' },
{
path: '/catchall/:path*?',
regex: '^/catchall(?:/(.*))?$',
paramNames: ['path'],
},
],
});
});

test('should generate correct pattern for catchall route', () => {
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);
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 @@
// 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,86 @@
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({
routes: [
{ path: '/' },
{
path: '/dynamic/:id',
regex: '^/dynamic/([^/]+)$',
paramNames: ['id'],
},
{ path: '/static/nested' },
{
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.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);
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.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);
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.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);
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.routes.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
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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({
routes: [
{ path: '/' },
{ path: '/javascript' },
{ path: '/jsx-route' },
{ path: '/mixed' },
{ path: '/precedence' },
{ path: '/typescript' },
],
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Auth layout
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Login page
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Signup page
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Dynamic dashboard page
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Dashboard page
Loading